Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Change log

- 1.1.0 - Add support for additional GitHub-hosted scopes via `~/.lpm/config.json`
(or per-project `lpmConfig.scopes`). Default behavior is unchanged
when no config file is present. `lpm publish` remains restricted to
`@loupeteam` for now.

- 1.0.6 - Fix installation of packages as source.
- Add pytesting infrastructure.

Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,43 @@ LPM is the Loupe Package Manager. This tool is designed to make it easy to inter

Documentation for LPM use, including detailed installation instructions and use cases, can be found [here](https://loupeteam.github.io/LoupeDocs/tools/lpm.html).

## Custom registries / additional scopes

LPM ships configured for the `@loupeteam` scope (backed by the `loupeteam` GitHub organization on `https://npm.pkg.github.com`). Other organizations can register their own GitHub-hosted scopes without modifying LPM source.

Create `~/.lpm/config.json` (or set `LPM_CONFIG_PATH` to point elsewhere):

```json
{
"defaultScope": "@loupeteam",
"scopes": {
"@acme": {
"org": "acme-corp",
"registry": "https://npm.pkg.github.com"
}
}
}
```

Per-project overrides may be added under the `lpmConfig` key in a project's `package.json`:

```json
{
"lpmConfig": {
"scopes": {
"@acme": { "org": "acme-corp", "registry": "https://npm.pkg.github.com" }
}
}
}
```

Behavior with extra scopes configured:
- `lpm install <name>` still auto-prefixes bare names with the **default** scope. To install from another scope, use the explicit name: `lpm install @acme/foo`.
- `lpm login` writes a `<scope>:registry=...` line in `~/.npmrc` for every configured scope. A single GitHub PAT covers all scopes hosted on `npm.pkg.github.com`.
- `lpm viewall` queries every configured scope's GitHub organization and prints a combined list with a `Scope` column.
- `lpm publish` is currently restricted to `@loupeteam` only. Multi-scope publishing is tracked as a future enhancement.

If no config file is present, LPM behaves identically to previous releases (loupeteam-only).

## Licensing
This project is licensed under the [MIT License](LICENSE.md).
39 changes: 33 additions & 6 deletions src/LPM.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

# Core LPM functionality. The CLI delegates to functions defined in lpm_core.
from lpm_core import *
import lpm_config


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -66,17 +67,32 @@ def _plain_print(text, color):


def _normalize_packages(raw_packages):
"""Prepend the @loupeteam scope and split @version suffixes."""
"""Prepend the configured default scope and split @version suffixes.

If a user passes an already-scoped name like ``@acme/foo`` it is left
intact so additional registries (configured via ``lpm_config``) work.
"""
default_scope = lpm_config.get_default_scope()
packages = []
versions = []
for item in raw_packages or []:
item = item.lower()
if '@' in item:
if item.startswith('@') and '/' in item:
# Already-scoped name (e.g. "@acme/foo" or "@acme/foo@1.2.3").
# Split on the last '@' if there's a version suffix.
if item.count('@') >= 2:
at_pos = item.rfind('@')
packages.append(item[:at_pos])
versions.append(item[at_pos+1:])
else:
packages.append(item)
versions.append('')
elif '@' in item:
name, _, version = item.partition('@')
packages.append('@loupeteam/' + name)
packages.append(f'{default_scope}/' + name)
versions.append(version)
else:
packages.append('@loupeteam/' + item)
packages.append(f'{default_scope}/' + item)
versions.append('')
return packages, versions

Expand Down Expand Up @@ -237,7 +253,10 @@ def cmd_init(args):
print(colored('This directory has been initialized as a stand-alone package manager.', 'green'))
else:
initializeProject()
starterProject = ['@loupeteam/starterasproject49']
# TODO: support per-scope starter projects. For now we always
# bootstrap from the default scope (which is @loupeteam unless the
# user has overridden it in their lpm config).
starterProject = [f'{lpm_config.get_default_scope()}/starterasproject49']
installPackages(starterProject, [''])
syncPackages(starterProject)
configureProject(args)
Expand Down Expand Up @@ -339,7 +358,15 @@ def cmd_git(args):

def cmd_publish(args):
data = getPackageManifestData('package.json')
if data['name'].find('@loupeteam') != 0:
# Publishing is currently restricted to the @loupeteam scope. The
# multi-scope refactor wires everything else through lpm_config, but the
# publish flow has not been validated end-to-end against user-added
# scopes/registries yet.
# TODO: replace this guard with `lpm_config.get_scope_info(scope)` once the
# publish flow is verified for user-added scopes. The check would become:
# scope = lpm_config.scope_for_package(data['name'])
# if lpm_config.get_scope_info(scope) is None: ...
if not data['name'].startswith('@loupeteam/'):
cprint('Error: the package name must include the @loupeteam scope prefix.', 'yellow')
return
try:
Expand Down
179 changes: 179 additions & 0 deletions src/lpm_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
'''LPM scope/registry configuration.

File: lpm_config.py
Copyright (c) 2026 Loupe (https://loupe.team).
This file is part of LPM, licensed under the MIT License.

LPM was originally hardcoded to the @loupeteam npm scope (backed by the
`loupeteam` GitHub organization on npm.pkg.github.com). This module generalizes
that so users can register additional scopes without modifying LPM source.

Configuration is layered (later overrides earlier):
1. Built-in defaults (always include @loupeteam -> loupeteam org).
2. User-global config: ~/.lpm/config.json (or $LPM_CONFIG_PATH if set).
3. Per-project config: ./package.json -> "lpmConfig" with optional
"scopes" and "defaultScope" keys.

Each scope entry has the shape:
"@scope": { "org": "<github-org>", "registry": "<registry-url>" }

A user with no config files installed sees identical behavior to the
original loupeteam-only LPM.
'''

import os
import os.path
import json


DEFAULT_SCOPE = '@loupeteam'
DEFAULT_ORG = 'loupeteam'
DEFAULT_REGISTRY = 'https://npm.pkg.github.com'

_BUILTIN_SCOPES = {
DEFAULT_SCOPE: {'org': DEFAULT_ORG, 'registry': DEFAULT_REGISTRY},
}

# Cached config for the lifetime of the process. The CLI is short-lived, so
# a single load per invocation is fine.
_cached_config = None


def _user_config_path():
override = os.environ.get('LPM_CONFIG_PATH')
if override:
return override
return os.path.join(os.path.expanduser('~'), '.lpm', 'config.json')


def _read_json(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
return None


def _normalize_scope_key(scope):
if not scope:
return scope
if not scope.startswith('@'):
return '@' + scope
return scope


def _normalize_scopes_dict(raw):
"""Coerce any user-provided scopes mapping to the canonical shape."""
if not isinstance(raw, dict):
return {}
out = {}
for key, value in raw.items():
scope_key = _normalize_scope_key(key)
if not isinstance(value, dict):
continue
org = value.get('org')
if not org:
continue
registry = value.get('registry') or DEFAULT_REGISTRY
out[scope_key] = {'org': org, 'registry': registry}
return out


def load_config(force_reload=False):
"""Load and merge LPM configuration. Result shape:
{ 'defaultScope': '@scope',
'scopes': { '@scope': {'org': str, 'registry': str}, ... } }
"""
global _cached_config
if _cached_config is not None and not force_reload:
return _cached_config

scopes = dict(_BUILTIN_SCOPES)
default_scope = DEFAULT_SCOPE

# Layer 2: user-global config.
user_data = _read_json(_user_config_path())
if isinstance(user_data, dict):
scopes.update(_normalize_scopes_dict(user_data.get('scopes')))
if user_data.get('defaultScope'):
default_scope = _normalize_scope_key(user_data['defaultScope'])

# Layer 3: per-project package.json -> lpmConfig.
project_pkg = _read_json(os.path.join('.', 'package.json'))
if isinstance(project_pkg, dict):
lpm_config = project_pkg.get('lpmConfig')
if isinstance(lpm_config, dict):
scopes.update(_normalize_scopes_dict(lpm_config.get('scopes')))
if lpm_config.get('defaultScope'):
default_scope = _normalize_scope_key(lpm_config['defaultScope'])

# Guarantee the default scope is resolvable. If a user removed @loupeteam
# and pointed defaultScope somewhere unconfigured, fall back to @loupeteam.
if default_scope not in scopes:
default_scope = DEFAULT_SCOPE

_cached_config = {'defaultScope': default_scope, 'scopes': scopes}
return _cached_config


def reset_cache():
"""Test hook: drop the cached config so the next call re-reads files."""
global _cached_config
_cached_config = None


def get_default_scope():
return load_config()['defaultScope']


def get_scopes():
"""Return a copy of the {scope: {org, registry}} mapping."""
return dict(load_config()['scopes'])


def get_scope_info(scope):
scope = _normalize_scope_key(scope)
return load_config()['scopes'].get(scope)


def get_org_for_scope(scope):
info = get_scope_info(scope)
if info is None:
# Unknown scope: fall back to the default org so we degrade rather than
# crash. Callers that need strictness should check get_scope_info first.
return load_config()['scopes'][get_default_scope()]['org']
return info['org']


def get_registry_for_scope(scope):
info = get_scope_info(scope)
if info is None:
return load_config()['scopes'][get_default_scope()]['registry']
return info['registry']


def scope_for_package(package_name):
"""Extract the @scope prefix from a package name, or return the default."""
if package_name and package_name.startswith('@') and '/' in package_name:
return package_name.split('/', 1)[0]
return get_default_scope()


def org_for_package(package_name):
return get_org_for_scope(scope_for_package(package_name))


def strip_scope(package_name):
"""Return the bare package name (no @scope/ prefix)."""
if package_name and package_name.startswith('@') and '/' in package_name:
return package_name.split('/', 1)[1]
return package_name


def apply_default_scope(package_name):
"""Prepend the default scope if `package_name` is not already scoped."""
if package_name and package_name.startswith('@') and '/' in package_name:
return package_name
return f'{get_default_scope()}/{package_name}'
Loading