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>
111 lines
4.6 KiB
JavaScript
111 lines
4.6 KiB
JavaScript
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));
|
|
});
|
|
});
|