diff --git a/CHANGELOG.md b/CHANGELOG.md index 71eff4e..db19294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 61c6bb3..9ed63c2 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 `: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). diff --git a/src/LPM.py b/src/LPM.py index 451aed3..3f2c93a 100644 --- a/src/LPM.py +++ b/src/LPM.py @@ -32,6 +32,7 @@ # Core LPM functionality. The CLI delegates to functions defined in lpm_core. from lpm_core import * +import lpm_config # --------------------------------------------------------------------------- @@ -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 @@ -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) @@ -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: diff --git a/src/lpm_config.py b/src/lpm_config.py new file mode 100644 index 0000000..2654c3f --- /dev/null +++ b/src/lpm_config.py @@ -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": "", "registry": "" } + +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}' diff --git a/src/lpm_core.py b/src/lpm_core.py index e8ae223..6372568 100644 --- a/src/lpm_core.py +++ b/src/lpm_core.py @@ -22,11 +22,14 @@ import sys import re import requests +from urllib.parse import urlparse from termcolor import colored, cprint from ASPython import ASTools +import lpm_config + def isAuthenticated(): command = [] @@ -82,8 +85,9 @@ def importLibraries(): # Read the list of existing packages. packages_to_import = [] package_versions_to_import = [] + default_scope = lpm_config.get_default_scope() for library in loupePkg.objects: - packages_to_import.append('@loupeteam/' + library.text) + packages_to_import.append(f'{default_scope}/' + library.text) package_versions_to_import.append(library.version) # Install them. try: @@ -102,8 +106,26 @@ def importLibraries(): def login(token): f = open(os.path.join(os.path.expanduser('~'), '.npmrc'), 'w') text = [] - text.append('@loupeteam:registry=https://npm.pkg.github.com') - text.append('//npm.pkg.github.com/:_authToken=' + token) + # Write a registry mapping line for every configured scope. All scopes + # currently share the GitHub Packages host, so a single auth token line + # covers them. We deduplicate auth lines by host in case a user later + # configures a non-GitHub registry. + auth_hosts_written = set() + for scope, info in lpm_config.get_scopes().items(): + text.append(f'{scope}:registry={info["registry"]}') + for scope, info in lpm_config.get_scopes().items(): + # Use urlparse so registries with paths (e.g. + # https://npm.pkg.github.com/some-path) or embedded credentials still + # produce a clean host for the auth line. + host = urlparse(info['registry']).netloc + if not host: + # Misconfigured registry URL; skip rather than write a broken + # ///:_authToken= line. + continue + if host in auth_hosts_written: + continue + auth_hosts_written.add(host) + text.append(f'//{host}/:_authToken=' + token) f.write('\n'.join(text)) f.close() @@ -112,12 +134,22 @@ def logout(): # If there's a local .npmrc file, remove it. if(os.path.exists('./.npmrc')): os.remove('./.npmrc') - # And perform the npm logout to globally logout as well. - command = [] - command.append('npm logout') - command.append('--scope=@loupeteam') - command.append('--registry=https://npm.pkg.github.com') - executeStandard(command) + # And perform the npm logout per configured scope to globally logout as well. + failed = [] + for scope, info in lpm_config.get_scopes().items(): + command = [] + command.append('npm logout') + command.append(f'--scope={scope}') + command.append(f'--registry={info["registry"]}') + try: + executeStandard(command) + except Exception as exc: + # Don't let a failure on one scope abort the rest, but record + # the failure so the user knows logout was incomplete. + failed.append((scope, info['registry'], exc)) + if failed: + for scope, registry, exc in failed: + cprint(f"Warning: npm logout failed for {scope} ({registry}): {exc}", 'yellow') # Remove all LPM references from the project. def deleteProject(): @@ -254,9 +286,21 @@ def installSource(package, version, sourceDependencies=None): username = getAuthenticatedUser() password = getLocalToken() # Clone the repo into the target directory. + # Resolve the org from the package's scope. We require the scope + # to be configured rather than silently falling back to the + # default org -- a typo (e.g. @looupeteam/foo) must not result in + # a clone from the wrong organization. + scope = lpm_config.scope_for_package(package) + if lpm_config.get_scope_info(scope) is None: + raise Exception( + f"Cannot install source for {package}: scope {scope} is not " + "configured. Add it to ~/.lpm/config.json or your project's " + "lpmConfig.scopes before installing as source." + ) + org = lpm_config.get_org_for_scope(scope) command1 = [] command1.append('git clone') - command1.append(f'https://{username}:{password}@github.com/loupeteam/{repoName}') + command1.append(f'https://{username}:{password}@github.com/{org}/{repoName}') command1.append(repoPath) execute(command1, False) else: @@ -427,7 +471,7 @@ def readLoupeLibraryList(): # Retrieves repo name of a package by fetching data from GitHub def getRepoName(package): - (error, data) = getLoupePackageData(package) + (error, data) = getPackageData(package) if error is None: fullUrl = data.get('repository', {}).get('html_url', None) if fullUrl is None: @@ -460,7 +504,7 @@ def getAllDependencies(packages): # If there is no package.json for this package, assume it's a source library, and that it's already sync'd # to the Logical View under Libraries / Loupe. else: - # Strip it of its @loupeteam prefix. + # Strip it of its @ prefix. splitPackage = os.path.split(package)[1] dependencyData = getLibrarySourceDependencies(os.path.join('.', 'Logical', 'Libraries', 'Loupe', splitPackage)) print('Source dependencies: ') @@ -482,17 +526,21 @@ def getAllDependencies(packages): def getLibrarySourceDependencies(libraryPath): sourceLibrary = ASTools.Library(libraryPath) dependencyNames = [] + # TODO: probe every configured scope rather than only the default scope. + # Today this only resolves dependencies that live under the default scope's + # registry; cross-scope dependencies need to be encoded explicitly upstream. + default_scope = lpm_config.get_default_scope() # Install binary dependencies for this library for dependency in sourceLibrary.dependencies: - # First check to see if it's a custom Loupe lib (if not, ignore it) + # First check to see if it's a known package in the default scope (if not, ignore it). command = [] command.append('npm view') - command.append(f'@loupeteam/{dependency.name}') + command.append(f'{default_scope}/{dependency.name}') result = executeAndReturnCode(command) if result == 0: - print('Dependency found: ' + f'@loupeteam/{dependency.name.lower()}') + print('Dependency found: ' + f'{default_scope}/{dependency.name.lower()}') # Add this dependency to our list. - dependencyNames.append(f'@loupeteam/{dependency.name}'.lower()) + dependencyNames.append(f'{default_scope}/{dependency.name}'.lower()) return dependencyNames def getProgramSourceDependencies(programSourcePath): @@ -529,21 +577,25 @@ def syncPackages(packages): elif((packageType == 'program') | (packageType == 'package')): destination = getPackageDestination(packageManifest) - # Find the module(s) in node_modules, and sync it/them. - for module in os.listdir(os.path.join('node_modules', '@loupeteam')): - if (os.path.join('@loupeteam', module) == os.path.normpath(package)): - # Get a handle on the folder destination. - destinationPkg = ASTools.Package(destination) - # Create a list of filtered objects that don't get copied over. - filter = ['package.pkg', 'license', 'readme.md', 'package.json', 'changelog.md'] - # Loop through all contents in the source directory and copy them over one by one. - for item in os.listdir(os.path.join('node_modules', '@loupeteam', module)): - if (item.lower() not in filter): - # If the item already exists, delete it. - destinationItem = os.path.join(destination, item) - if os.path.exists(destinationItem): - destinationPkg.removeObject(item) - destinationPkg.addObject(os.path.join('node_modules', package, item)) + # Find the module(s) in node_modules across every configured scope, and sync it/them. + for scope in lpm_config.get_scopes().keys(): + scopeDir = os.path.join('node_modules', scope) + if not os.path.isdir(scopeDir): + continue + for module in os.listdir(scopeDir): + if (os.path.join(scope, module) == os.path.normpath(package)): + # Get a handle on the folder destination. + destinationPkg = ASTools.Package(destination) + # Create a list of filtered objects that don't get copied over. + filter = ['package.pkg', 'license', 'readme.md', 'package.json', 'changelog.md'] + # Loop through all contents in the source directory and copy them over one by one. + for item in os.listdir(os.path.join(scopeDir, module)): + if (item.lower() not in filter): + # If the item already exists, delete it. + destinationItem = os.path.join(destination, item) + if os.path.exists(destinationItem): + destinationPkg.removeObject(item) + destinationPkg.addObject(os.path.join('node_modules', package, item)) elif(packageType == 'library') or (packageType == None): packageDestination = getPackageManifestField(packageManifest, ['lpm', 'logical', 'destination']) @@ -554,16 +606,20 @@ def syncPackages(packages): destination = os.path.join('Logical', 'Libraries', 'Loupe') # Now create the packages in this path that doesn't exist. createPackageTree(destination) - # Find the module(s) in node_modules, and sync it/them. - for module in os.listdir(os.path.join('node_modules', '@loupeteam')): - if (os.path.join('@loupeteam', module) == os.path.normpath(package)): - # Get a handle on the library's parent folder. - parentPkg = ASTools.Package(destination) - # If the library already exists, delete it. - libraryPath = os.path.join(destination, module) - if os.path.isdir(libraryPath): - parentPkg.removeObject(module) - parentPkg.addObject(os.path.join('node_modules', package)) + # Find the module(s) in node_modules across every configured scope, and sync it/them. + for scope in lpm_config.get_scopes().keys(): + scopeDir = os.path.join('node_modules', scope) + if not os.path.isdir(scopeDir): + continue + for module in os.listdir(scopeDir): + if (os.path.join(scope, module) == os.path.normpath(package)): + # Get a handle on the library's parent folder. + parentPkg = ASTools.Package(destination) + # If the library already exists, delete it. + libraryPath = os.path.join(destination, module) + if os.path.isdir(libraryPath): + parentPkg.removeObject(module) + parentPkg.addObject(os.path.join('node_modules', package)) def deployPackages(config, packages): # Figure out where the deployment table is for this configuration. @@ -664,13 +720,15 @@ def createPackageTree(packages: list): def createLibraryManifest(package, lpmConfig): library = ASTools.Library('.') + default_scope = lpm_config.get_default_scope() + default_org = lpm_config.get_org_for_scope(default_scope) # Create dependencies dictionary for this library dependency_dict = {} for dependency in library.dependencies: - # First check to see if it's a custom Loupe lib (if not, ignore it) + # First check to see if it's a known package in the default scope (if not, ignore it). cmd = [] cmd.append('npm view') - cmd.append(f'@loupeteam/{dependency.name}') + cmd.append(f'{default_scope}/{dependency.name}') result = executeAndReturnCode(cmd) if result == 0: version = [] @@ -680,14 +738,19 @@ def createLibraryManifest(package, lpmConfig): version.append(f'<={library._formatVersionString(dependency.maxVersion)}') if len(version) == 0: version.append('*') - dependency_dict.update({f'@loupeteam/{dependency.name.lower()}':' '.join(version)}) + dependency_dict.update({f'{default_scope}/{dependency.name.lower()}':' '.join(version)}) # Make sure there's a top level description available. if (library.description == ''): - description = f"Loupe's {package.lower()} library for Automation Runtime" + description = f"{package.lower()} library for Automation Runtime" else: description = library.description - # Set the homepage to point to the styleguide - homepage = f'https://loupeteam.github.io/LoupeDocs/libraries/{package.lower()}.html' + # Set the homepage. Only emit the LoupeDocs URL when publishing under the + # @loupeteam scope; other scopes can override later via a future + # `docsUrlTemplate` config option. + if default_scope == lpm_config.DEFAULT_SCOPE: + homepage = f'https://loupeteam.github.io/LoupeDocs/libraries/{package.lower()}.html' + else: + homepage = '' # Ensure the lpmConfig has the proper type set. try: if lpmConfig['type'] != 'library': @@ -696,17 +759,17 @@ def createLibraryManifest(package, lpmConfig): lpmConfig = { 'type': 'library' } # Create dictionary that will hold all values for the package.json file manifest_dict = { - 'name': f'@loupeteam/{package.lower()}', + 'name': f'{default_scope}/{package.lower()}', 'version': library._formatVersionString(library.version), 'description': description, 'homepage': homepage, 'scripts': {}, 'keywords': [], - 'author': 'Loupe', + 'author': default_org, 'license': 'MIT', 'repository': { 'type': 'git', - 'url': 'https://github.com/loupeteam/' + package + 'url': f'https://github.com/{default_org}/' + package }, 'lpm': lpmConfig, 'dependencies': dependency_dict @@ -748,85 +811,123 @@ def setPackageManifestField(manifest, fieldName, fieldData): def printLoupePackageList(): print("Retrieving package data...") - (error, data) = getLoupePackageListData() + (error, data) = getPackageListData() - if not error: - packages_sorted = sorted(data, key=lambda x: x["name"]) + if error: + print(f"Unable to print package list: {error}") + return - # Pre-process package descriptions; these are handled separately, as they can be None. - package_descriptions = [] - for package in packages_sorted: - try: - if package['repository']['description'] is not None: - package_descriptions.append(package['repository']['description']) - else: - package_descriptions.append(" ") - except: - package_descriptions.append(" ") + if not data: + print("No packages found.") + return - # Determine column widths. - name_col_width = max(len(package["name"]) for package in packages_sorted) + 2 - version_col_width = 12 - lastmod_col_width = 14 - description_col_width = max(len(description) for description in package_descriptions) - - # Print the header. - print( "NAME".ljust(name_col_width) + - "VERSIONS".ljust(version_col_width) + - "LASTUPDATED".ljust(lastmod_col_width) + - "DESCRIPTION".ljust(description_col_width)) - print( "----".ljust(name_col_width) + - "--------".ljust(version_col_width) + - "-----------".ljust(lastmod_col_width) + - "-----------".ljust(description_col_width)) - - for idx, package in enumerate(packages_sorted): - print( package["name"].ljust(name_col_width) + - str(package["version_count"]).ljust(version_col_width) + - package["updated_at"][:10].ljust(lastmod_col_width) + - package_descriptions[idx].ljust(description_col_width)) - else: - print(f"Unable to print package list: {error}") + packages_sorted = sorted(data, key=lambda x: x["name"]) + + # Pre-process package descriptions; these are handled separately, as they can be None. + package_descriptions = [] + for package in packages_sorted: + try: + if package['repository']['description'] is not None: + package_descriptions.append(package['repository']['description']) + else: + package_descriptions.append(" ") + except: + package_descriptions.append(" ") + + # Determine column widths. + name_col_width = max(len(package["name"]) for package in packages_sorted) + 2 + scope_col_width = max((len(package.get("_lpm_scope", "")) for package in packages_sorted), default=6) + 2 + scope_col_width = max(scope_col_width, len("SCOPE") + 2) + version_col_width = 12 + lastmod_col_width = 14 + description_col_width = max((len(description) for description in package_descriptions), default=len("DESCRIPTION")) + + # Print the header. + print( "NAME".ljust(name_col_width) + + "SCOPE".ljust(scope_col_width) + + "VERSIONS".ljust(version_col_width) + + "LASTUPDATED".ljust(lastmod_col_width) + + "DESCRIPTION".ljust(description_col_width)) + print( "----".ljust(name_col_width) + + "-----".ljust(scope_col_width) + + "--------".ljust(version_col_width) + + "-----------".ljust(lastmod_col_width) + + "-----------".ljust(description_col_width)) + + for idx, package in enumerate(packages_sorted): + print( package["name"].ljust(name_col_width) + + package.get("_lpm_scope", "").ljust(scope_col_width) + + str(package["version_count"]).ljust(version_col_width) + + package["updated_at"][:10].ljust(lastmod_col_width) + + package_descriptions[idx].ljust(description_col_width)) # Fetches data using GitHub API (See https://docs.github.com/en/rest/packages?apiVersion=2022-11-28#list-packages-for-an-organization) -# Returns (error, data) tuple, where error is None if all OK and data is a list of package dictionaries (see GitHub's schema) -def getLoupePackageListData(): +# When `scope` is None, queries every configured scope's org and concatenates the +# results, tagging each entry with its originating scope under `_lpm_scope`. +# Returns (error, data) tuple. +def getPackageListData(scope=None): token = getLocalToken() headers = { 'Authorization': f'Bearer {token}', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } - organization = 'loupeteam' - page = 1 - per_page = 100 - all_packages_gathered = False - all_packages = [] - while not all_packages_gathered: - params = { 'package_type': 'npm', - 'page': str(page), - 'per_page': str(per_page) } - r = requests.get(f'https://api.github.com/orgs/{organization}/packages', headers=headers, params=params, timeout=5) - if r.status_code != 200: - error = "Status code not OK. Code: " + str(r.status_code) + "\n" + r.text - return (error, []) # Early return - retrieved_packages = json.loads(r.content) - all_packages += retrieved_packages - all_packages_gathered = len(retrieved_packages) < per_page # All gathered once there are fewer results than full amount - page += 1 + if scope is None: + scopes_to_query = list(lpm_config.get_scopes().items()) + else: + info = lpm_config.get_scope_info(scope) + if info is None: + return (f"Unknown scope: {scope}", []) + scopes_to_query = [(scope, info)] + all_packages = [] + errors = [] + for scope_name, info in scopes_to_query: + organization = info['org'] + page = 1 + per_page = 100 + all_packages_gathered = False + while not all_packages_gathered: + params = { 'package_type': 'npm', + 'page': str(page), + 'per_page': str(per_page) } + try: + r = requests.get(f'https://api.github.com/orgs/{organization}/packages', headers=headers, params=params, timeout=5) + except requests.RequestException as exc: + errors.append(f"{scope_name} ({organization}): {exc}") + break + if r.status_code != 200: + errors.append(f"{scope_name} ({organization}): status {r.status_code} - {r.text}") + break + retrieved_packages = json.loads(r.content) + for pkg in retrieved_packages: + pkg['_lpm_scope'] = scope_name + all_packages += retrieved_packages + all_packages_gathered = len(retrieved_packages) < per_page + page += 1 + + if not all_packages and errors: + return ("; ".join(errors), []) + if errors: + # Surface partial failures via stderr-ish print but still return data. + print("Warning: some scopes failed to query: " + "; ".join(errors)) print(f"Retrieved {len(all_packages)} packages total. See below for detailed information.") return (None, all_packages) -# Fetches data using GitHub API (See https://docs.github.com/en/rest/packages?apiVersion=2022-11-28#list-packages-for-an-organization) -# Returns (error, data) tuple, where error is None if all OK and data is a dictionary of the desired package (see GitHub's schema) -def getLoupePackageData(packageName: str): +# Backward-compatible alias for callers that still expect the old name. +def getLoupePackageListData(): + return getPackageListData() + +# Fetches data using GitHub API for a single package; resolves the org from the +# package's @scope prefix. +# Returns (error, data) tuple. +def getPackageData(packageName: str): token = getLocalToken() headers = { 'Authorization': f'Bearer {token}', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } - organization = 'loupeteam' + organization = lpm_config.org_for_package(packageName) - packageNameStripped = os.path.split(packageName)[1] # Strip it of its @loupeteam prefix. + packageNameStripped = lpm_config.strip_scope(packageName) r = requests.get(f'https://api.github.com/orgs/{organization}/packages/npm/{packageNameStripped}', headers=headers, timeout=5) if r.status_code != 200: error = "Status code not OK. Code: " + str(r.status_code) + "\n" + r.text @@ -834,6 +935,10 @@ def getLoupePackageData(packageName: str): packageData = json.loads(r.content) return (None, packageData) +# Backward-compatible alias. +def getLoupePackageData(packageName: str): + return getPackageData(packageName) + # Run a generic NPM command on the specified packages. def runGenericNpmCmd(cmd, packages): command = [] diff --git a/test/test_lpm_config.py b/test/test_lpm_config.py new file mode 100644 index 0000000..1f71d15 --- /dev/null +++ b/test/test_lpm_config.py @@ -0,0 +1,138 @@ +"""Unit tests for the multi-scope configuration layer. + +These tests do not exercise npm or GitHub; they only validate the loading, +merging, and helper logic in `lpm_config`, plus how the CLI's +`_normalize_packages` honors the configured default scope. +""" + +import json +import os +import sys +import importlib + +import pytest + +sys.path.insert(0, "./src") +sys.path.insert(0, "./src/ASPython") + +import lpm_config # noqa: E402 +import LPM # noqa: E402 + + +@pytest.fixture +def isolated_config(tmp_path, monkeypatch): + """Point lpm_config at a throwaway config file and reset its cache.""" + cfg_path = tmp_path / "config.json" + monkeypatch.setenv("LPM_CONFIG_PATH", str(cfg_path)) + # Run from a directory with no project package.json so per-project layer is + # a no-op for these tests. + monkeypatch.chdir(tmp_path) + lpm_config.reset_cache() + yield cfg_path + lpm_config.reset_cache() + + +def _write_config(path, data): + path.write_text(json.dumps(data), encoding="utf-8") + lpm_config.reset_cache() + + +def test_default_config_when_no_file(isolated_config): + cfg = lpm_config.load_config() + assert cfg["defaultScope"] == "@loupeteam" + assert "@loupeteam" in cfg["scopes"] + assert cfg["scopes"]["@loupeteam"]["org"] == "loupeteam" + + +def test_user_config_adds_extra_scope(isolated_config): + _write_config(isolated_config, { + "scopes": { + "@acme": {"org": "acme-corp", "registry": "https://npm.pkg.github.com"} + } + }) + cfg = lpm_config.load_config() + # Built-in default scope is preserved alongside the user-added one. + assert "@loupeteam" in cfg["scopes"] + assert cfg["scopes"]["@acme"]["org"] == "acme-corp" + assert lpm_config.get_default_scope() == "@loupeteam" + + +def test_user_config_overrides_default_scope(isolated_config): + _write_config(isolated_config, { + "defaultScope": "@acme", + "scopes": { + "@acme": {"org": "acme-corp", "registry": "https://npm.pkg.github.com"} + } + }) + assert lpm_config.get_default_scope() == "@acme" + + +def test_unknown_default_scope_falls_back(isolated_config): + _write_config(isolated_config, {"defaultScope": "@nonexistent"}) + # Falls back to @loupeteam rather than crashing. + assert lpm_config.get_default_scope() == "@loupeteam" + + +def test_scope_for_package_extracts_or_defaults(isolated_config): + _write_config(isolated_config, { + "scopes": { + "@acme": {"org": "acme-corp", "registry": "https://npm.pkg.github.com"} + } + }) + assert lpm_config.scope_for_package("@acme/foo") == "@acme" + assert lpm_config.scope_for_package("foo") == "@loupeteam" + assert lpm_config.org_for_package("@acme/foo") == "acme-corp" + assert lpm_config.org_for_package("foo") == "loupeteam" + + +def test_strip_scope(isolated_config): + assert lpm_config.strip_scope("@acme/foo") == "foo" + assert lpm_config.strip_scope("foo") == "foo" + + +def test_normalize_packages_uses_default_scope(isolated_config): + # Bare names get the loupeteam prefix; existing scopes are preserved. + pkgs, versions = LPM._normalize_packages(["foo", "@acme/bar", "baz@1.2.3", "@acme/qux@2.0.0"]) + assert pkgs == ["@loupeteam/foo", "@acme/bar", "@loupeteam/baz", "@acme/qux"] + assert versions == ["", "", "1.2.3", "2.0.0"] + + +def test_normalize_packages_honors_overridden_default(isolated_config): + _write_config(isolated_config, { + "defaultScope": "@acme", + "scopes": { + "@acme": {"org": "acme-corp", "registry": "https://npm.pkg.github.com"} + } + }) + pkgs, _ = LPM._normalize_packages(["foo"]) + assert pkgs == ["@acme/foo"] + + +def test_login_writes_registry_line_per_scope(isolated_config, monkeypatch, tmp_path): + # Stage a fake HOME so login() writes to a sandbox. + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) # Windows + _write_config(isolated_config, { + "scopes": { + "@acme": {"org": "acme-corp", "registry": "https://npm.pkg.github.com"} + } + }) + + import lpm_core + importlib.reload(lpm_core) # rebind os.path.expanduser('~') usage at call time + lpm_config.reset_cache() + # Re-apply the env after reload (reload re-imports os). + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) + monkeypatch.setenv("LPM_CONFIG_PATH", str(isolated_config)) + + lpm_core.login("dummy-token-xyz") + + npmrc = (fake_home / ".npmrc").read_text(encoding="utf-8") + assert "@loupeteam:registry=https://npm.pkg.github.com" in npmrc + assert "@acme:registry=https://npm.pkg.github.com" in npmrc + assert "//npm.pkg.github.com/:_authToken=dummy-token-xyz" in npmrc + # Auth token should appear exactly once (deduped by host). + assert npmrc.count("_authToken=") == 1