diff --git a/README.md b/README.md index a4c7ce0..b075847 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,26 @@ read by both hosts. The behaviour layer above only works if the agents can actually call `lh` — it's what runs `lh init`/`lh doctor`/`lh graph`/etc. under the hood. Published to **GitHub Packages** (`npm.pkg.github.com`), a private, org-scoped registry — not the public npm registry, so it -needs one extra step: +needs one extra step. `scripts/setup-npm-registry.mjs` (zero dependencies) does it for you: ```bash -# one-time: point the @redsentech scope at GitHub Packages, with a token that has read:packages -echo "@redsentech:registry=https://npm.pkg.github.com" >> ~/.npmrc -echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> ~/.npmrc - +git clone git@github.com:redsentech/lean-harness.git && cd lean-harness && npm install +node scripts/setup-npm-registry.mjs # finds a token (flag > env > `gh auth token` > prompt), + # verifies it, writes ~/.npmrc, confirms npm can reach + # the registry — never prints the token in full npm install -g @redsentech/lean-harness # or: npx @redsentech/lean-harness lh doctor # verify the environment ``` +Equivalent by hand, if you already have a token with `read:packages`: + +```bash +echo "@redsentech:registry=https://npm.pkg.github.com" >> ~/.npmrc +echo "//npm.pkg.github.com/:_authToken=" >> ~/.npmrc +``` + +Undo either with `node scripts/setup-npm-registry.mjs --unset`. + No published version yet? Clone and link instead: ```bash diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 43193c0..b70796f 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -190,6 +190,7 @@ Everything the harness does is self-documenting — nothing lives only in a chat | `lh` not found after onboarding | Re-run without `--no-npm-link`, or invoke via the absolute path the script prints | | Agents/skills installed via `copilot plugin install` but `lh init`/`lh doctor` fail with "command not found" | Expected — plugin install only adds the behaviour layer. Run `npm link` from a clone (path C) or `scripts/onboard.mjs` (path B) to get `lh` on `PATH` | | `copilot plugin install owner/repo` prints a deprecation warning | Expected for direct-source installs. Use `copilot plugin marketplace add` + `copilot plugin install name@marketplace` instead (path A) | +| `npm install -g @redsentech/lean-harness` gives `404`/`403` | `.npmrc` isn't pointed at GitHub Packages, or the token lacks `read:packages`. Run `node scripts/setup-npm-registry.mjs` | | Pipeline stuck at design gate | `interrogator` is waiting on your answers — this is intentional, answer the questions | ## Next steps diff --git a/package.json b/package.json index 3de2965..b28eb15 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "validate": "node scripts/validate.mjs", "test": "node --test tests/*.test.mjs", "lh": "node src/cli.mjs", - "onboard": "node scripts/onboard.mjs" + "onboard": "node scripts/onboard.mjs", + "setup-npm-registry": "node scripts/setup-npm-registry.mjs" }, "dependencies": { "web-tree-sitter": "^0.25.10" diff --git a/scripts/setup-npm-registry.mjs b/scripts/setup-npm-registry.mjs new file mode 100644 index 0000000..d32f31e --- /dev/null +++ b/scripts/setup-npm-registry.mjs @@ -0,0 +1,298 @@ +#!/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(); diff --git a/tests/setup-npm-registry.test.mjs b/tests/setup-npm-registry.test.mjs new file mode 100644 index 0000000..4c1ef3c --- /dev/null +++ b/tests/setup-npm-registry.test.mjs @@ -0,0 +1,110 @@ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = join(ROOT, 'scripts', 'setup-npm-registry.mjs'); + +const created = []; +after(() => { + while (created.length) { + try { rmSync(created.pop(), { recursive: true, force: true }); } catch {} + } +}); + +function tempNpmrcPath() { + const dir = mkdtempSync(join(tmpdir(), 'lh-npmrc-')); + created.push(dir); + return join(dir, '.npmrc'); +} + +// Never hits the network or `gh`: --token supplies the token directly and +// --skip-verify skips the GitHub API round-trip, so these tests are hermetic. +function setup(args, env = {}) { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + return { code: 0, stdout, stderr: '' }; + } catch (e) { + return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' }; + } +} + +describe('setup-npm-registry script', () => { + test('--dry-run writes nothing', () => { + const npmrc = tempNpmrcPath(); + const r = setup(['--dry-run', '--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`]); + assert.equal(r.code, 0); + assert.ok(!existsSync(npmrc), 'dry run must not create the file'); + assert.match(r.stdout, /would write/); + }); + + test('writes scope + auth token entries, preserving unrelated lines', () => { + const npmrc = tempNpmrcPath(); + writeFileSync(npmrc, 'registry=https://registry.npmjs.org/\n//existing-line=keep-me\n'); + const r = setup(['--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`]); + assert.equal(r.code, 0); + const content = readFileSync(npmrc, 'utf8'); + assert.match(content, /registry=https:\/\/registry\.npmjs\.org\//); + assert.match(content, /existing-line=keep-me/); + assert.match(content, /@redsentech:registry=https:\/\/npm\.pkg\.github\.com/); + assert.match(content, /\/\/npm\.pkg\.github\.com\/:_authToken=fake-token/); + }); + + test('never prints the token in full', () => { + const npmrc = tempNpmrcPath(); + const r = setup(['--token=super-secret-token-value', '--skip-verify', `--npmrc=${npmrc}`]); + assert.equal(r.code, 0); + assert.ok(!r.stdout.includes('super-secret-token-value'), 'full token must never be printed'); + }); + + test('rerun is idempotent (no duplicate entries)', () => { + const npmrc = tempNpmrcPath(); + setup(['--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`]); + setup(['--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`]); + const content = readFileSync(npmrc, 'utf8'); + const matches = content.match(/@redsentech:registry=/g) || []; + assert.equal(matches.length, 1, 'entries must not be duplicated across reruns'); + }); + + test('--unset removes only this scope/registry, keeps everything else', () => { + const npmrc = tempNpmrcPath(); + writeFileSync(npmrc, 'registry=https://registry.npmjs.org/\n'); + setup(['--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`]); + const r = setup(['--unset', `--npmrc=${npmrc}`]); + assert.equal(r.code, 0); + const content = readFileSync(npmrc, 'utf8'); + assert.match(content, /registry=https:\/\/registry\.npmjs\.org\//); + assert.ok(!content.includes('@redsentech:registry='), 'scope entry must be removed'); + assert.ok(!content.includes('_authToken='), 'auth token entry must be removed'); + }); + + test('custom --scope and --registry are honoured', () => { + const npmrc = tempNpmrcPath(); + const r = setup(['--token=fake-token', '--skip-verify', `--npmrc=${npmrc}`, '--scope=@other', '--registry=https://example.test']); + assert.equal(r.code, 0); + const content = readFileSync(npmrc, 'utf8'); + assert.match(content, /@other:registry=https:\/\/example\.test/); + assert.match(content, /\/\/example\.test\/:_authToken=fake-token/); + }); + + test('fails cleanly with --yes and no token available anywhere', () => { + const npmrc = tempNpmrcPath(); + const r = setup(['--yes', `--npmrc=${npmrc}`], { + NPM_REGISTRY_TOKEN: '', + GITHUB_TOKEN: '', + GH_TOKEN: '', + PATH: '/nonexistent', // hide `gh` from PATH so gh-CLI discovery can't accidentally succeed + }); + assert.equal(r.code, 1); + assert.match(r.stdout + r.stderr, /no token available/); + assert.ok(!existsSync(npmrc)); + }); +});