feat: zero-dependency npm registry setup script

scripts/setup-npm-registry.mjs configures npm to pull @redsentech
packages (e.g. @redsentech/lean-harness) from GitHub Packages:

- Discovers a token: --token flag > env (NPM_REGISTRY_TOKEN/
  GITHUB_TOKEN/GH_TOKEN) > `gh auth token` > interactive masked prompt
  (raw-mode stdin, no echo, no external deps).
- Verifies the token against the GitHub API, reports the authenticated
  login, and warns if the OAuth scopes are missing read:packages/
  write:packages.
- Writes/updates only its own two lines in .npmrc (scope registry +
  auth token), preserving every other line; idempotent on rerun.
- Never prints the full token (masked in all output).
- --dry-run, --unset (clean removal), --local, --scope, --registry,
  --skip-verify, --npmrc <path> flags.
- Confirms the result with `npm whoami --registry ...`.

Verified live end-to-end: real GitHub PAT via `gh auth token` ->
verified against api.github.com -> written to a scratch .npmrc ->
`npm whoami` succeeded. Also confirmed the scope-warning is accurate:
installing @redsentech/lean-harness with a token lacking read:packages
correctly 403s, exactly as the script warns it will.

Adds tests/setup-npm-registry.test.mjs (7 hermetic tests: dry-run,
write+preserve, token never printed, idempotent rerun, --unset,
custom scope/registry, clean failure with no token). Adds
"setup-npm-registry" npm script. Documents the script in README (The
`lh` CLI section) and docs/QUICKSTART.md (install + troubleshooting).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-10 00:56:30 +02:00
co-authored by Copilot
parent 8330cf7900
commit 58471c286f
5 changed files with 425 additions and 6 deletions
+14 -5
View File
@@ -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 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** `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 (`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 ```bash
# one-time: point the @redsentech scope at GitHub Packages, with a token that has read:packages git clone git@github.com:redsentech/lean-harness.git && cd lean-harness && npm install
echo "@redsentech:registry=https://npm.pkg.github.com" >> ~/.npmrc node scripts/setup-npm-registry.mjs # finds a token (flag > env > `gh auth token` > prompt),
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> ~/.npmrc # 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 <command> npm install -g @redsentech/lean-harness # or: npx @redsentech/lean-harness <command>
lh doctor # verify the environment 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=<your token>" >> ~/.npmrc
```
Undo either with `node scripts/setup-npm-registry.mjs --unset`.
No published version yet? Clone and link instead: No published version yet? Clone and link instead:
```bash ```bash
+1
View File
@@ -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 | | `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` | | 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) | | `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 | | Pipeline stuck at design gate | `interrogator` is waiting on your answers — this is intentional, answer the questions |
## Next steps ## Next steps
+2 -1
View File
@@ -31,7 +31,8 @@
"validate": "node scripts/validate.mjs", "validate": "node scripts/validate.mjs",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"lh": "node src/cli.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": { "dependencies": {
"web-tree-sitter": "^0.25.10" "web-tree-sitter": "^0.25.10"
+298
View File
@@ -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=<url> registry URL (default: https://npm.pkg.github.com)
* --token=<token> use this token instead of discovering one
* --npmrc=<path> .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=<url> registry URL (default: https://npm.pkg.github.com)
--token=<token> use this token instead of discovering one
--npmrc=<path> .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();
+110
View File
@@ -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));
});
});