#!/usr/bin/env node /** * scripts/setup-npm-registry.mjs — configure npm to pull @redsentech packages * (including @redsentech/lean-harness) from GitHub Packages. * * GitHub Packages is a private, org-scoped npm registry — plain `npm install` * does not know about it until the consuming scope is mapped to it, with a * token that has at least `read:packages`. This script gets that token * (flag > env > `gh auth token` > interactive masked prompt) and writes the * two required lines into an .npmrc, without ever printing the token in full * or storing it anywhere else. * * Usage: * node scripts/setup-npm-registry.mjs [options] * * Options: * --scope=@name npm scope to map (default: @redsentech) * --registry= registry URL (default: https://npm.pkg.github.com) * --token= use this token instead of discovering one * --npmrc= .npmrc to edit (default: ~/.npmrc) * --local edit ./.npmrc in the current directory instead * --yes never prompt; fail if no token can be found non-interactively * --dry-run print the plan, write nothing * --skip-verify don't call the GitHub/npm APIs to validate the token * --unset remove this scope's entries instead of adding them * -h, --help show this help */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; const NO_COLOR = process.env.NO_COLOR !== undefined || !process.stdout.isTTY; const wrap = (code, s) => (NO_COLOR ? s : `\u001b[${code}m${s}\u001b[0m`); const dim = (s) => wrap('2', s); const bold = (s) => wrap('1', s); const red = (s) => wrap('31', s); const green = (s) => wrap('32', s); const yellow = (s) => wrap('33', s); const out = (l = '') => process.stdout.write(`${l}\n`); const err = (l) => process.stderr.write(`${l}\n`); const ok = (m) => out(`${green('ok')} ${m}`); const warn = (m) => out(`${yellow('warn')} ${m}`); const fail = (m) => err(`${red('fail')} ${m}`); const step = (m) => out(`\n${bold(m)}`); function parseArgs(argv) { const flags = {}; for (const arg of argv) { if (!arg.startsWith('--')) continue; const [key, value] = arg.slice(2).split(/=(.*)/s); flags[key] = value === undefined ? true : value; } return flags; } function usage() { out(`usage: node scripts/setup-npm-registry.mjs [options] --scope=@name npm scope to map (default: @redsentech) --registry= registry URL (default: https://npm.pkg.github.com) --token= use this token instead of discovering one --npmrc= .npmrc to edit (default: ~/.npmrc) --local edit ./.npmrc in the current directory instead --yes never prompt; fail if no token can be found non-interactively --dry-run print the plan, write nothing --skip-verify don't call GitHub/npm to validate the token --unset remove this scope's entries instead of adding them -h, --help show this help`); } function run(cmd, args, opts = {}) { try { const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }); return { code: 0, stdout, stderr: '' }; } catch (e) { return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? e) }; } } function mask(token) { if (!token) return '(none)'; if (token.length <= 8) return '*'.repeat(token.length); return `${token.slice(0, 4)}${'*'.repeat(token.length - 8)}${token.slice(-4)}`; } // ---- token discovery -------------------------------------------------- function tokenFromEnv() { return process.env.NPM_REGISTRY_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; } function tokenFromGhCli() { const version = run('gh', ['--version']); if (version.code !== 0) return null; const token = run('gh', ['auth', 'token']); if (token.code !== 0) return null; return token.stdout.trim() || null; } // Reads a line of input with the terminal echo suppressed, so the token // never appears on screen. Falls back to a visible prompt when stdin isn't a // TTY (e.g. piped input) — there's nothing to hide in that case anyway. function promptHidden(question) { return new Promise((resolve) => { process.stdout.write(question); if (!process.stdin.isTTY) { let data = ''; process.stdin.on('data', (chunk) => (data += chunk)); process.stdin.on('end', () => resolve(data.trim())); return; } const chars = []; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); const onData = (char) => { switch (char) { case '\n': case '\r': case '\u0004': // Ctrl-D process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.removeListener('data', onData); process.stdout.write('\n'); resolve(chars.join('')); break; case '\u0003': // Ctrl-C process.stdout.write('\n'); process.exit(130); break; case '\u007f': // backspace chars.pop(); break; default: chars.push(char); } }; process.stdin.on('data', onData); }); } async function discoverToken({ explicit, yes }) { if (explicit) return { token: explicit, source: '--token' }; const envToken = tokenFromEnv(); if (envToken) return { token: envToken, source: 'environment (NPM_REGISTRY_TOKEN/GITHUB_TOKEN/GH_TOKEN)' }; const ghToken = tokenFromGhCli(); if (ghToken) return { token: ghToken, source: '`gh auth token`' }; if (yes) return { token: null, source: null }; out(dim('No token found via flag, environment, or `gh auth token`.')); out(dim('Create one with at least the "read:packages" scope:')); out(dim(' https://github.com/settings/tokens/new?scopes=read:packages&description=npm-registry')); const token = await promptHidden('Paste a GitHub token (input hidden): '); return { token: token || null, source: 'interactive prompt' }; } // ---- token verification ------------------------------------------------- async function verifyToken(token) { try { const res = await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'redsen-lean-harness-setup-script' }, }); if (!res.ok) return { ok: false, detail: `GitHub API responded ${res.status}` }; const body = await res.json(); const scopesHeader = res.headers.get('x-oauth-scopes'); const scopes = scopesHeader ? scopesHeader.split(',').map((s) => s.trim()).filter(Boolean) : null; const hasPackagesScope = scopes ? scopes.some((s) => s === 'read:packages' || s === 'write:packages') : null; return { ok: true, login: body.login, scopes, hasPackagesScope }; } catch (e) { return { ok: false, detail: e.message }; } } // ---- .npmrc editing ------------------------------------------------------ function npmrcLines(npmrcPath) { if (!fs.existsSync(npmrcPath)) return []; return fs.readFileSync(npmrcPath, 'utf8').split('\n'); } function registryHost(registryUrl) { return new URL(registryUrl).host; } function buildEntries(scope, registryUrl, token) { return [`${scope}:registry=${registryUrl}`, `//${registryHost(registryUrl)}/:_authToken=${token}`]; } // Removes any previous lines for this exact scope/registry pair, then (unless // unsetting) appends the fresh ones. Leaves every other line in the file // untouched — this script only ever owns its own two lines. function planNpmrc({ lines, scope, registryUrl, token, unset }) { const host = registryHost(registryUrl); const scopeRe = new RegExp(`^${scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:registry=`); const authRe = new RegExp(`^//${host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/:_authToken=`); const kept = lines.filter((l) => !scopeRe.test(l) && !authRe.test(l)); // Drop one trailing blank line the filter may have exposed, then re-add a // single separating blank line before our block for readability. while (kept.length && kept[kept.length - 1] === '') kept.pop(); const next = unset ? kept : [...kept, '', ...buildEntries(scope, registryUrl, token)]; return next.join('\n') + '\n'; } async function main() { const flags = parseArgs(process.argv.slice(2)); if (flags.help || flags.h) { usage(); process.exit(0); } const scope = flags.scope || '@redsentech'; const registryUrl = flags.registry || 'https://npm.pkg.github.com'; const npmrcPath = flags.local ? path.resolve(process.cwd(), '.npmrc') : path.resolve(flags.npmrc || path.join(os.homedir(), '.npmrc')); const dryRun = Boolean(flags['dry-run']); const yes = Boolean(flags.yes); const unset = Boolean(flags.unset); out(bold('redsen-lean-harness — npm registry setup')); out(dim(`scope: ${scope}`)); out(dim(`registry: ${registryUrl}`)); out(dim(`.npmrc: ${npmrcPath}`)); if (dryRun) out(dim('(dry run — nothing will be written)')); if (unset) { step('removing entries'); const lines = npmrcLines(npmrcPath); const next = planNpmrc({ lines, scope, registryUrl, token: '', unset: true }); if (dryRun) { out(dim('would write:')); out(next); } else { fs.writeFileSync(npmrcPath, next, { mode: 0o600 }); ok(`removed ${scope} / ${registryHost(registryUrl)} entries from ${npmrcPath}`); } return; } step('1. token'); const { token, source } = await discoverToken({ explicit: flags.token, yes }); if (!token) { fail('no token available and none provided (run without --yes to be prompted, or pass --token)'); process.exit(1); } ok(`using token from ${source} (${mask(token)})`); if (!flags['skip-verify']) { step('2. verify'); const result = await verifyToken(token); if (!result.ok) { fail(`could not verify token against GitHub API: ${result.detail}`); fail('the token may still work for the registry — re-run with --skip-verify to bypass this check'); process.exit(1); } ok(`authenticated as ${result.login}`); if (result.scopes === null) { warn('token type does not report OAuth scopes (fine-grained PAT or App token) — cannot pre-check read:packages'); } else if (!result.hasPackagesScope) { warn(`token scopes [${result.scopes.join(', ')}] may be missing read:packages — install may fail`); } else { ok('token has a scope that covers read:packages'); } } else { step('2. verify'); warn('skipped (--skip-verify)'); } step('3. write .npmrc'); const lines = npmrcLines(npmrcPath); const next = planNpmrc({ lines, scope, registryUrl, token, unset: false }); if (dryRun) { out(dim(`would write (token masked as ${mask(token)}):`)); out(next.replace(token, mask(token))); } else { fs.mkdirSync(path.dirname(npmrcPath), { recursive: true }); fs.writeFileSync(npmrcPath, next, { mode: 0o600 }); ok(`wrote ${scope}:registry and auth token to ${npmrcPath} (mode 600)`); } if (!dryRun && !flags['skip-verify']) { step('4. confirm npm can reach the registry'); const whoami = run('npm', ['whoami', '--registry', registryUrl, '--userconfig', npmrcPath]); if (whoami.code === 0) ok(`npm whoami --registry ${registryUrl} -> ${whoami.stdout.trim()}`); else warn(`npm whoami failed (this can still be fine if the registry doesn't expose whoami): ${whoami.stderr.trim()}`); } step('done'); out(`Next: ${bold(`npm install -g ${scope}/lean-harness`)}`); out(dim(`Undo any time: node scripts/setup-npm-registry.mjs --unset --scope=${scope} --registry=${registryUrl}`)); } main();