feat(setup-npm-registry): browser-assisted classic PAT creation

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.
This commit is contained in:
2026-09-10 01:44:28 +02:00
parent 66219525ba
commit cf13066baa
5 changed files with 108 additions and 15 deletions
+57 -9
View File
@@ -6,9 +6,9 @@
* 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.
* (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]
@@ -20,6 +20,7 @@
* --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
@@ -63,6 +64,7 @@ function usage() {
--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
@@ -140,7 +142,33 @@ function promptHidden(question) {
});
}
async function discoverToken({ explicit, yes }) {
// 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();
@@ -151,11 +179,31 @@ async function discoverToken({ explicit, yes }) {
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`.'));
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' };
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 -------------------------------------------------
@@ -243,7 +291,7 @@ async function main() {
}
step('1. token');
const { token, source } = await discoverToken({ explicit: flags.token, yes });
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);