From cf13066baa7ef063506e369ef44abe1a6c9c2fd2 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Thu, 10 Sep 2026 01:44:28 +0200 Subject: [PATCH] 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. --- README.md | 29 ++++++++++++-- docs/QUICKSTART.md | 3 +- package-lock.json | 4 +- scripts/setup-npm-registry.mjs | 66 ++++++++++++++++++++++++++----- tests/setup-npm-registry.test.mjs | 21 ++++++++++ 5 files changed, 108 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6213c69..a86a435 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,36 @@ needs one extra step. `scripts/setup-npm-registry.mjs` (zero dependencies) does ```bash git clone git@github.com:redsentech/lean-harness.git && cd lean-harness && npm install -node scripts/setup-npm-registry.mjs # finds a token (flag > env > `gh auth token` > prompt), - # verifies it, writes ~/.npmrc, confirms npm can reach - # the registry — never prints the token in full +node scripts/setup-npm-registry.mjs # finds a token (flag > env > `gh auth token` > browser- + # assisted classic-PAT creation + paste), 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 lh doctor # verify the environment ``` +#### Generating a GitHub token + +If no token is found via `--token`, `NPM_REGISTRY_TOKEN`/`GITHUB_TOKEN`/`GH_TOKEN`, or +`gh auth token`, the script walks you through creating one — no manual scope-hunting required: + +1. It opens `github.com/settings/tokens/new` in your default browser, pre-filled with the + `read:packages` scope and a memorable description (falls back to printing the URL if it + can't launch a browser — e.g. over SSH, in a container, or in CI). +2. On that page, set: + - **Note** — anything memorable, e.g. `npm-registry` + - **Expiration** — your choice (90 days is a reasonable default) + - **Scopes** — `read:packages` is pre-checked (required to install); also check + `write:packages` if you need to publish +3. Click **Generate token**, copy it (starts with `ghp_`) — GitHub only shows it once. +4. Paste it back into the terminal prompt (input is hidden). + +Only **classic** PATs work reliably with GitHub Packages — fine-grained tokens don't yet +support package scopes, so the script always links to the classic token page. + +Pass `--no-open` to skip the automatic browser launch and just print the URL (useful in +headless/SSH sessions where there's no display to open a browser on). + Equivalent by hand, if you already have a token with `read:packages`: ```bash diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 7ab5540..2d76bad 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -220,7 +220,8 @@ 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 | | 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) | -| `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` | +| `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` — see [Generating a GitHub token](../README.md#generating-a-github-token) if you don't have one yet | +| `setup-npm-registry.mjs` doesn't open a browser (SSH/headless) | Expected — it falls back to printing the token creation URL. Pass `--no-open` to skip the attempt entirely | | Pipeline stuck at design gate | `interrogator` is waiting on your answers — this is intentional, answer the questions | ## Next steps diff --git a/package-lock.json b/package-lock.json index 0a966ab..e40807e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@redsen/lean-harness", + "name": "@redsentech/lean-harness", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@redsen/lean-harness", + "name": "@redsentech/lean-harness", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/scripts/setup-npm-registry.mjs b/scripts/setup-npm-registry.mjs index d32f31e..2c9260c 100644 --- a/scripts/setup-npm-registry.mjs +++ b/scripts/setup-npm-registry.mjs @@ -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= .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= .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); diff --git a/tests/setup-npm-registry.test.mjs b/tests/setup-npm-registry.test.mjs index 4c1ef3c..98cd37a 100644 --- a/tests/setup-npm-registry.test.mjs +++ b/tests/setup-npm-registry.test.mjs @@ -95,6 +95,27 @@ describe('setup-npm-registry script', () => { assert.match(content, /\/\/example\.test\/:_authToken=fake-token/); }); + test('--no-open prints the classic-PAT URL and accepts a pasted token via stdin', () => { + const npmrc = tempNpmrcPath(); + const r = execFileSync(process.execPath, [ + SCRIPT, '--no-open', '--skip-verify', `--npmrc=${npmrc}`, + ], { + encoding: 'utf8', + input: 'ghp_pastedtoken1234\n', + env: { + ...process.env, + NPM_REGISTRY_TOKEN: '', + GITHUB_TOKEN: '', + GH_TOKEN: '', + PATH: '/nonexistent', // hide `gh` so gh-CLI discovery can't short-circuit the prompt + }, + }); + assert.match(r, /github\.com\/settings\/tokens\/new\?scopes=read%3Apackages/); + assert.match(r, /--no-open.*skipping automatic browser launch/); + const content = readFileSync(npmrc, 'utf8'); + assert.match(content, /_authToken=ghp_pastedtoken1234/); + }); + test('fails cleanly with --yes and no token available anywhere', () => { const npmrc = tempNpmrcPath(); const r = setup(['--yes', `--npmrc=${npmrc}`], {