When no token is found via --token/env/gh CLI, the script now opens github.com/settings/tokens/new pre-scoped to read:packages with clear instructions on Note/Expiration/Scopes, falling back to printing the URL when a browser can't be launched (SSH, containers, CI). Add --no-open to skip the launch attempt outright. Also document the flow in README (new 'Generating a GitHub token' section) and QUICKSTART troubleshooting, and add a hermetic test covering the --no-open + piped-token path.
347 lines
14 KiB
JavaScript
347 lines
14 KiB
JavaScript
#!/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` > browser-assisted classic-PAT creation +
|
|
* paste) 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
|
|
* --no-open don't try to open the token creation page in a browser
|
|
* --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
|
|
--no-open don't try to open the token creation page in a browser
|
|
--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);
|
|
});
|
|
}
|
|
|
|
// GitHub Packages' npm registry only accepts classic personal access tokens
|
|
// (fine-grained PATs don't reliably carry package scopes yet). This builds
|
|
// the pre-filled "New personal access token (classic)" page: the scopes
|
|
// query param pre-ticks the checkboxes so the user doesn't have to hunt for
|
|
// them in a long list of ~40 scopes.
|
|
function classicTokenUrl({ scopes, description }) {
|
|
const url = new URL('https://github.com/settings/tokens/new');
|
|
url.searchParams.set('scopes', scopes.join(','));
|
|
url.searchParams.set('description', description);
|
|
return url.toString();
|
|
}
|
|
|
|
// Best-effort browser launch. Never throws — if there's no display (SSH,
|
|
// container, CI) or no matching opener, the caller falls back to printing
|
|
// the URL for the user to open by hand.
|
|
function openBrowser(url) {
|
|
const platform = os.platform();
|
|
const [cmd, args] = platform === 'darwin'
|
|
? ['open', [url]]
|
|
: platform === 'win32'
|
|
? ['cmd', ['/c', 'start', '""', url]]
|
|
: ['xdg-open', [url]];
|
|
const result = run(cmd, args, { stdio: 'ignore' });
|
|
return result.code === 0;
|
|
}
|
|
|
|
async function discoverToken({ explicit, yes, noOpen }) {
|
|
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 };
|
|
|
|
const tokenUrl = classicTokenUrl({ scopes: ['read:packages'], description: 'npm-registry (GitHub Packages)' });
|
|
|
|
out(dim('No token found via flag, environment, or `gh auth token`.'));
|
|
step('Create a GitHub personal access token (classic)');
|
|
out(`GitHub Packages' npm registry only works with a ${bold('classic')} PAT — fine-grained`);
|
|
out('tokens don\'t reliably support package scopes yet. On the page that opens (or the URL');
|
|
out('below), set:');
|
|
out(` ${bold('Note')} anything memorable, e.g. "npm-registry"`);
|
|
out(` ${bold('Expiration')} your choice (90 days is a reasonable default)`);
|
|
out(` ${bold('Scopes')} check ${bold('read:packages')} (required, to install)`);
|
|
out(` also check ${bold('write:packages')} if you also need to publish`);
|
|
out('Then click "Generate token" and copy it (starts with `ghp_`) — GitHub only shows it once.');
|
|
out();
|
|
const opened = !noOpen && process.stdin.isTTY && openBrowser(tokenUrl);
|
|
if (opened) {
|
|
ok('Opened the token creation page in your browser.');
|
|
} else if (noOpen) {
|
|
out(dim('(--no-open) skipping automatic browser launch — open this URL by hand:'));
|
|
} else {
|
|
warn('Could not open a browser automatically (no display, SSH session, or CI). Open this URL by hand:');
|
|
}
|
|
out(` ${tokenUrl}`);
|
|
out();
|
|
const token = await promptHidden('Paste the generated token here (input hidden): ');
|
|
return { token: token || null, source: 'interactive prompt (classic PAT page)' };
|
|
}
|
|
|
|
// ---- 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, noOpen: flags['no-open'] });
|
|
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();
|