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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user