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
+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));
});
});