Skip to content

GitHub Package Registry Setup

Configure NuGet and NPM to use the moodysanalytics GitHub Packages feed

Metadata

  • Author: ropean, Claude Sonnet (Anthropic)
  • Version: 1.0.0

Code

python
#!/usr/bin/env python3
"""
@title GitHub Package Registry Setup
@description Configure NuGet and NPM to use the moodysanalytics GitHub Packages feed
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0

Sets up the NuGet source and NPM registry for the moodysanalytics GitHub
Packages feed so that `dotnet` and `npm` can pull private packages.

PAT resolution priority:
    1. --token CLI argument
    2. GITHUB_TOKEN environment variable
    3. Interactive prompt (password-masked)

Exit codes:
    0 - all requested configurations succeeded or already in place
    1 - a required configuration failed

@example
Usage example:
    python dotnet-registry.py                # interactive menu
    python dotnet-registry.py --all          # configure both NuGet and NPM
    python dotnet-registry.py --nuget        # NuGet only
    python dotnet-registry.py --npm          # NPM only
    python dotnet-registry.py --check        # show current status
    python dotnet-registry.py --show-token   # show saved PATs
"""

import argparse
import getpass
import json
import os
import re
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET

# ── Constants ──────────────────────────────────────────────────────────────────

GITHUB_SOURCE_NAME = "github"
GITHUB_NUGET_URL = "https://nuget.pkg.github.com/moodysanalytics/index.json"
GITHUB_NPM_URL = "https://npm.pkg.github.com"
GITHUB_ORG = "moodysanalytics"
TEST_PACKAGE = "ZMAPI-Windows-X64.Resource"
WIKI_URL = "https://moodysanalytics.atlassian.net/wiki/spaces/CAO/pages/470036157/Connect+to+GitHub+Package+Registry"

# ── Colored output helpers ─────────────────────────────────────────────────────

_NO_COLOR = not sys.stdout.isatty()


def _colored(text: str, code: str) -> str:
    if _NO_COLOR:
        return text
    return f"\033[{code}m{text}\033[0m"


def green(text: str) -> str:
    return _colored(text, "32")


def yellow(text: str) -> str:
    return _colored(text, "33")


def red(text: str) -> str:
    return _colored(text, "31")


def cyan(text: str) -> str:
    return _colored(text, "36")


def dim(text: str) -> str:
    return _colored(text, "90")


# ── PAT resolution ────────────────────────────────────────────────────────────

_resolved_pat: str | None = None


def resolve_pat(cli_token: str | None) -> str | None:
    """Resolve PAT from CLI arg > env var > interactive prompt. Cached."""
    global _resolved_pat
    if _resolved_pat is not None:
        return _resolved_pat

    if cli_token:
        _resolved_pat = cli_token
        print(dim("  Using PAT from --token argument."))
        return _resolved_pat

    env_token = os.environ.get("GITHUB_TOKEN", "").strip()
    if env_token:
        _resolved_pat = env_token
        print(dim("  Using PAT from GITHUB_TOKEN environment variable."))
        return _resolved_pat

    print()
    print("  A GitHub Personal Access Token (PAT) with read:packages scope is required.")
    print(f"  See: {cyan(WIKI_URL)}")
    print()
    try:
        pat = getpass.getpass("  Enter your GitHub PAT: ").strip()
    except (EOFError, KeyboardInterrupt):
        print()
        return None

    if not pat:
        return None

    _resolved_pat = pat
    return _resolved_pat


# ── Subprocess helpers ─────────────────────────────────────────────────────────


def run_cmd(args: list[str], *, timeout: int = 30) -> subprocess.CompletedProcess:
    # shell=True is required on Windows so that .cmd/.bat wrappers (npm, dotnet) resolve correctly
    return subprocess.run(args, capture_output=True, text=True, timeout=timeout, shell=(os.name == "nt"))


# ── NuGet configuration ───────────────────────────────────────────────────────


def _parse_nuget_sources() -> dict[str, bool]:
    """Return {source_name: is_enabled} from `dotnet nuget list source`."""
    result = run_cmd(["dotnet", "nuget", "list", "source"])
    sources: dict[str, bool] = {}
    for line in result.stdout.splitlines():
        # Lines look like:  "  1.  github [Enabled]"  or  "  2.  nuget.org [Disabled]"
        m = re.match(r"\s*\d+\.\s+(.+?)\s+\[(Enabled|Disabled)\]", line)
        if m:
            sources[m.group(1)] = m.group(2) == "Enabled"
    return sources


def _test_nuget_source() -> bool:
    """Return True if a test package search returns at least one version."""
    print(dim(f"  Testing: dotnet package search {TEST_PACKAGE} --source {GITHUB_SOURCE_NAME} ..."))
    try:
        result = run_cmd([
            "dotnet", "package", "search", TEST_PACKAGE,
            "--source", GITHUB_SOURCE_NAME,
            "--exact-match", "--format", "json",
        ], timeout=60)
    except subprocess.TimeoutExpired:
        print(yellow("  Test search timed out."))
        return False

    if result.returncode != 0:
        return False

    try:
        data = json.loads(result.stdout)
    except (json.JSONDecodeError, ValueError):
        return False

    for source in data.get("searchResult", []):
        for pkg in source.get("packages", []):
            if pkg.get("version"):
                return True
    return False


def _add_nuget_source(pat: str) -> bool:
    """Add and authenticate the github NuGet source. Return True on success."""
    print(dim(f"  Adding NuGet source '{GITHUB_SOURCE_NAME}' -> {GITHUB_NUGET_URL}"))
    result = run_cmd([
        "dotnet", "nuget", "add", "source", GITHUB_NUGET_URL,
        "--name", GITHUB_SOURCE_NAME,
    ])
    if result.returncode != 0:
        print(red(f"  Failed to add source: {result.stderr.strip()}"))
        return False

    return _update_nuget_credentials(pat)


def _update_nuget_credentials(pat: str) -> bool:
    """Update credentials for the github NuGet source. Return True on success."""
    print(dim(f"  Updating credentials for source '{GITHUB_SOURCE_NAME}'..."))
    result = run_cmd([
        "dotnet", "nuget", "update", "source", GITHUB_SOURCE_NAME,
        "--username", GITHUB_ORG,
        "--password", pat,
        "--store-password-in-clear-text",
    ])
    if result.returncode != 0:
        print(red(f"  Failed to update credentials: {result.stderr.strip()}"))
        return False
    return True


def configure_nuget(cli_token: str | None) -> bool:
    """Configure the NuGet source for GitHub Packages. Return True on success."""
    print()
    print(cyan("── NuGet Source Configuration ──"))
    print()

    if not shutil.which("dotnet"):
        print(red("  ERROR: 'dotnet' CLI not found. Run dotnet-setup.py first."))
        return False

    sources = _parse_nuget_sources()

    if GITHUB_SOURCE_NAME in sources:
        enabled = sources[GITHUB_SOURCE_NAME]
        if not enabled:
            print(yellow(f"  Source '{GITHUB_SOURCE_NAME}' exists but is disabled. Enabling..."))
            run_cmd(["dotnet", "nuget", "enable", "source", GITHUB_SOURCE_NAME])

        if _test_nuget_source():
            print(green(f"  NuGet source '{GITHUB_SOURCE_NAME}' is configured and working."))
            return True

        # Source exists but test failed — PAT may be expired
        print(yellow(f"  Source '{GITHUB_SOURCE_NAME}' exists but test search returned no results."))
        print(yellow("  Your PAT may be expired or lack read:packages scope."))
        print()
        pat = resolve_pat(cli_token)
        if not pat:
            print(red("  No PAT provided. Cannot reconfigure."))
            return False
        if not _update_nuget_credentials(pat):
            return False
        if _test_nuget_source():
            print(green(f"  NuGet source '{GITHUB_SOURCE_NAME}' is now working."))
            return True
        print(red("  NuGet source still not working after credential update."))
        print(yellow(f"  Verify your PAT has read:packages scope. See: {WIKI_URL}"))
        return False

    # Source does not exist — add it
    pat = resolve_pat(cli_token)
    if not pat:
        print(red("  No PAT provided. Cannot add NuGet source."))
        return False
    if not _add_nuget_source(pat):
        return False
    if _test_nuget_source():
        print(green(f"  NuGet source '{GITHUB_SOURCE_NAME}' configured and verified."))
        return True
    print(yellow("  NuGet source added but test search returned no results."))
    print(yellow(f"  Verify your PAT has read:packages scope. See: {WIKI_URL}"))
    return False


# ── NPM configuration ─────────────────────────────────────────────────────────


def _get_npm_registry() -> str | None:
    """Return the currently configured @moodysanalytics registry, or None."""
    result = run_cmd(["npm", "config", "get", f"@{GITHUB_ORG}:registry"])
    if result.returncode != 0:
        return None
    value = result.stdout.strip()
    if value in ("undefined", ""):
        return None
    return value


def _npmrc_path() -> str:
    return os.path.join(os.path.expanduser("~"), ".npmrc")


def _set_npm_auth_token(pat: str) -> None:
    """Ensure //npm.pkg.github.com/:_authToken=<PAT> is in ~/.npmrc."""
    npmrc = _npmrc_path()
    token_line = f"//npm.pkg.github.com/:_authToken={pat}"
    token_prefix = "//npm.pkg.github.com/:_authToken="

    lines: list[str] = []
    if os.path.isfile(npmrc):
        with open(npmrc, encoding="utf-8", errors="replace") as f:
            lines = f.readlines()

    updated = False
    new_lines: list[str] = []
    for line in lines:
        if line.strip().startswith(token_prefix):
            new_lines.append(token_line + "\n")
            updated = True
        else:
            new_lines.append(line)

    if not updated:
        if new_lines and not new_lines[-1].endswith("\n"):
            new_lines.append("\n")
        new_lines.append(token_line + "\n")

    with open(npmrc, "w", encoding="utf-8") as f:
        f.writelines(new_lines)
    print(dim(f"  Updated {npmrc}"))


def configure_npm(cli_token: str | None) -> bool:
    """Configure NPM registry for GitHub Packages. Return True on success."""
    print()
    print(cyan("── NPM Registry Configuration ──"))
    print()

    if not shutil.which("npm"):
        print(dim("  npm not found on PATH. Skipping NPM configuration."))
        return True  # not a failure — npm is optional

    current = _get_npm_registry()
    if current and current.rstrip("/") == GITHUB_NPM_URL:
        print(green(f"  NPM registry for @{GITHUB_ORG} is already configured."))
        return True

    pat = resolve_pat(cli_token)
    if not pat:
        print(red("  No PAT provided. Cannot configure NPM registry."))
        return False

    print(dim(f"  Setting @{GITHUB_ORG}:registry -> {GITHUB_NPM_URL}"))
    result = run_cmd(["npm", "config", "set", f"@{GITHUB_ORG}:registry", GITHUB_NPM_URL])
    if result.returncode != 0:
        print(red(f"  Failed to set NPM registry: {result.stderr.strip()}"))
        return False

    _set_npm_auth_token(pat)
    print(green(f"  NPM registry for @{GITHUB_ORG} configured successfully."))
    return True


# ── Interactive menu ───────────────────────────────────────────────────────────


def show_menu() -> str | None:
    """Show interactive menu. Return 'nuget', 'npm', 'both', or None to exit."""
    print()
    print("  Select what to configure:")
    print()
    print(f"  {cyan('[1]')} Configure NuGet source")
    print(f"  {cyan('[2]')} Configure NPM registry")
    print(f"  {cyan('[3]')} Configure both")
    print(f"  {cyan('[X]')} Exit")
    print()

    while True:
        try:
            choice = input("  Enter your choice (1-3, or X to exit): ").strip().upper()
        except (EOFError, KeyboardInterrupt):
            print()
            return None
        if choice == "1":
            return "nuget"
        if choice == "2":
            return "npm"
        if choice == "3":
            return "both"
        if choice == "X":
            return None
        print(red("  Invalid input. Please enter 1, 2, 3, or X."))


# ── Show saved tokens ──────────────────────────────────────────────────────────


def _nuget_config_paths() -> list[str]:
    """Return candidate NuGet.Config paths for the current platform."""
    paths: list[str] = []
    if sys.platform == "win32":
        appdata = os.environ.get("APPDATA", "")
        if appdata:
            paths.append(os.path.join(appdata, "NuGet", "NuGet.Config"))
    paths.append(os.path.join(os.path.expanduser("~"), ".nuget", "NuGet", "NuGet.Config"))
    paths.append(os.path.join(os.path.expanduser("~"), ".config", "NuGet", "NuGet.Config"))
    return paths


def _read_nuget_token() -> str | None:
    """Read ClearTextPassword for the github source from NuGet.Config."""
    for config_path in _nuget_config_paths():
        if not os.path.isfile(config_path):
            continue
        try:
            tree = ET.parse(config_path)
        except ET.ParseError:
            continue
        root = tree.getroot()
        creds = root.find(f".//packageSourceCredentials/{GITHUB_SOURCE_NAME}")
        if creds is None:
            continue
        for add in creds.findall("add"):
            if add.get("key") == "ClearTextPassword":
                return add.get("value")
    return None


def _read_npmrc_token() -> str | None:
    """Read //npm.pkg.github.com/:_authToken from ~/.npmrc."""
    npmrc = _npmrc_path()
    prefix = "//npm.pkg.github.com/:_authToken="
    if not os.path.isfile(npmrc):
        return None
    try:
        with open(npmrc, encoding="utf-8", errors="replace") as f:
            for line in f:
                stripped = line.strip()
                if stripped.startswith(prefix):
                    return stripped[len(prefix):]
    except OSError:
        pass
    return None


def _mask_token(token: str) -> str:
    """Show first 4 and last 4 chars, mask the rest."""
    if len(token) <= 12:
        return token[:4] + "*" * (len(token) - 4)
    return token[:4] + "*" * (len(token) - 8) + token[-4:]


def show_saved_tokens() -> None:
    """Print saved PATs from NuGet config and ~/.npmrc."""
    print()
    print(cyan("── Saved Tokens ──"))

    # NuGet
    print()
    nuget_token = _read_nuget_token()
    if nuget_token:
        print(f"  NuGet ({GITHUB_SOURCE_NAME}): {green(_mask_token(nuget_token))}")
        found_path = None
        for p in _nuget_config_paths():
            if os.path.isfile(p):
                try:
                    tree = ET.parse(p)
                    creds = tree.getroot().find(
                        f".//packageSourceCredentials/{GITHUB_SOURCE_NAME}"
                    )
                    if creds is not None:
                        found_path = p
                        break
                except ET.ParseError:
                    continue
        if found_path:
            print(dim(f"    from: {found_path}"))
    else:
        print(dim(f"  NuGet ({GITHUB_SOURCE_NAME}): not configured"))

    # NPM
    print()
    npm_token = _read_npmrc_token()
    if npm_token:
        print(f"  NPM  (@{GITHUB_ORG}):  {green(_mask_token(npm_token))}")
        print(dim(f"    from: {_npmrc_path()}"))
    else:
        print(dim(f"  NPM  (@{GITHUB_ORG}):  not configured"))

    # Check if they match
    if nuget_token and npm_token:
        if nuget_token == npm_token:
            print()
            print(dim("  Both tokens are the same PAT."))
        else:
            print()
            print(yellow("  Warning: NuGet and NPM are using different PATs."))

    print()


# ── Status check ──────────────────────────────────────────────────────────────


def check_status() -> None:
    """Print current NuGet and NPM configuration status."""
    print()
    print(cyan("── Configuration Status ──"))

    # NuGet
    print()
    if not shutil.which("dotnet"):
        print(red("  NuGet: dotnet CLI not found"))
    else:
        sources = _parse_nuget_sources()
        if GITHUB_SOURCE_NAME not in sources:
            print(red(f"  NuGet: source '{GITHUB_SOURCE_NAME}' not configured"))
        elif not sources[GITHUB_SOURCE_NAME]:
            print(yellow(f"  NuGet: source '{GITHUB_SOURCE_NAME}' exists but is disabled"))
        else:
            if _test_nuget_source():
                print(green(f"  NuGet: source '{GITHUB_SOURCE_NAME}' is configured and working"))
            else:
                print(yellow(f"  NuGet: source '{GITHUB_SOURCE_NAME}' exists but test search failed (PAT may be expired)"))

    # NPM
    print()
    if not shutil.which("npm"):
        print(dim("  NPM:   not installed (optional)"))
    else:
        registry = _get_npm_registry()
        if registry and registry.rstrip("/") == GITHUB_NPM_URL:
            print(green(f"  NPM:   @{GITHUB_ORG} registry is configured"))
        else:
            print(red(f"  NPM:   @{GITHUB_ORG} registry not configured"))

    # Tokens
    nuget_token = _read_nuget_token()
    npm_token = _read_npmrc_token()
    print()
    print(dim(f"  NuGet PAT: {'saved' if nuget_token else 'not found'}"))
    print(dim(f"  NPM   PAT: {'saved' if npm_token else 'not found'}"))
    print()


# ── Main ───────────────────────────────────────────────────────────────────────


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Configure GitHub Package Registry for NuGet and/or NPM.",
    )
    parser.add_argument("--token", help="GitHub Personal Access Token (PAT)")

    parser.add_argument(
        "--check", action="store_true",
        help="Check current NuGet/NPM configuration status, then exit",
    )
    parser.add_argument(
        "--show-token", action="store_true",
        help="Show saved PATs from NuGet config and ~/.npmrc, then exit",
    )

    group = parser.add_mutually_exclusive_group()
    group.add_argument("--all", action="store_true", help="Configure both NuGet and NPM (skip menu)")
    group.add_argument("--nuget", action="store_true", help="Configure NuGet only (skip menu)")
    group.add_argument("--npm", action="store_true", help="Configure NPM only (skip menu)")

    return parser.parse_args()


def main() -> int:
    args = parse_args()

    print()
    print(cyan("=== GitHub Package Registry Setup ==="))

    if args.check:
        check_status()
        return 0

    if args.show_token:
        show_saved_tokens()
        return 0

    # Always show current status first
    check_status()

    if args.all:
        scope = "both"
    elif args.nuget:
        scope = "nuget"
    elif args.npm:
        scope = "npm"
    else:
        scope = show_menu()
        if scope is None:
            print(yellow("  Exiting."))
            return 0

    ok = True

    if scope in ("nuget", "both"):
        if not configure_nuget(args.token):
            ok = False

    if scope in ("npm", "both"):
        if not configure_npm(args.token):
            ok = False

    print()
    if ok:
        print(green("Done."))
    else:
        print(red("One or more configurations failed. See messages above."))
    print()
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

File Information

  • Filename: dotnet-registry.py
  • Category: python
  • Language: PYTHON

View on GitHub