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
+26 -3
View File
@@ -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 <command>
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
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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": {
+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);
+21
View File
@@ -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}`], {