From 9da61f7e98ff01e747e1c76f73441a2aa4da4669 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 09:35:34 +0800 Subject: [PATCH 1/2] feat(release): npm-auth preflight fails closed before publish (#3) Co-Authored-By: Claude Fable 5 --- RELEASE.md | 74 +++++++++++++++ package.json | 3 +- scripts/check-npm-auth.mjs | 155 ++++++++++++++++++++++++++++++++ scripts/check-npm-auth.test.mjs | 94 +++++++++++++++++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 RELEASE.md create mode 100644 scripts/check-npm-auth.mjs create mode 100644 scripts/check-npm-auth.test.mjs diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..041ed61 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,74 @@ +# Release process + +This package ships to npm as `prd-taskmaster`. Releases are gated so that an +**auth failure is caught in one second, before any expensive tag/build/publish work** — +not after the build is half-done and `npm publish` hangs on an interactive web-auth wall. + +## Release gate order + +Run the gates in this exact order. Each one fails closed (non-zero exit) and stops the release: + +1. **`release:preflight` (npm auth)** — `npm run release:preflight` + - Confirms a usable npm token exists **before** anything else. + - Runs `scripts/check-npm-auth.mjs`. If no token is found it exits `1` immediately, + *before any network call*, so it can never stall on a browser login. +2. **version-sync** — `npm run version:check` + - `scripts/check-version-sync.js` — every version source-of-truth must agree + (`package.json`, `.claude-plugin/plugin.json`, `prd_taskmaster/__init__.py`). +3. **tag** — `git tag vX.Y.Z && git push --tags` +4. **publish** — `npm publish` + +### Automatic enforcement via `prepublishOnly` + +`npm publish` runs the package's `prepublishOnly` script first. It now runs **auth, then +version-sync**: + +```json +"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js" +``` + +So even if you forget to run `release:preflight` by hand, `npm publish` will refuse to +proceed when npm auth is missing/expired — auth is checked **before** the publish does any +real work. This is the fix for the recurring "publish stalls on a browser web-auth timeout" +failure: you now fail fast at the gate instead of mid-publish. + +## Auth: token path vs web-auth path + +`scripts/check-npm-auth.mjs` looks for a token in two places (pure, offline detection): + +### (a) Token path — preferred (non-interactive, no browser) + +Either of: + +- Set an environment variable: + ```bash + export NPM_TOKEN= + ``` + (The operator keeps an npm token in **Bitwarden** — retrieve it from there.) +- Or add a line to `~/.npmrc`: + ``` + //registry.npmjs.org/:_authToken= + ``` + +Then re-run `npm run release:preflight` to confirm. + +If a token is present, the preflight also runs `npm whoami` as a **soft** confirmation — +a network failure there is only a warning (the token may still be valid), and it never +blocks. The hard fail-closed behaviour only triggers when **no token at all** is found. + +### (b) Web-auth path — only if no token is available + +Run `npm login` in the operator's **real desktop browser** (already logged in, on their +own residential IP). Do **not** drive the login URL in an automated/headless browser — that +advertises automation and trips Cloudflare/Turnstile "Just a moment" challenges. Approve the +sign-in in one click, then re-run `npm run release:preflight`. + +## One-shot release checklist + +```bash +npm run release:preflight # 1. auth — fails closed if no token +npm run version:check # 2. versions agree +pytest tests/ # (sanity — full suite stays green) +git tag vX.Y.Z && git push --tags # 3. tag +npm publish # 4. publish (re-runs auth + version-sync via prepublishOnly) +``` diff --git a/package.json b/package.json index b5bbbf6..6bc8afd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ } }, "scripts": { - "prepublishOnly": "node scripts/check-version-sync.js", + "prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js", + "release:preflight": "node scripts/check-npm-auth.mjs", "version:check": "node scripts/check-version-sync.js", "postinstall": "pip install -r mcp-server/requirements.txt 2>/dev/null || echo 'WARN: Python MCP deps not installed. MCP tools will not start. Run: pip install -r node_modules/prd-taskmaster/mcp-server/requirements.txt'", "test": "pytest tests/", diff --git a/scripts/check-npm-auth.mjs b/scripts/check-npm-auth.mjs new file mode 100644 index 0000000..8861642 --- /dev/null +++ b/scripts/check-npm-auth.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Release-auth preflight — FAILS CLOSED before any expensive tag/build/publish work. + * + * The recurring failure this guards: `npm publish` stalls on a browser web-auth + * timeout because nobody checked that npm auth was actually valid FIRST. Wired ahead + * of version-sync in `prepublishOnly`, this refuses to start a publish when no usable + * npm token is present — so the operator finds out in one second, not after the tag/ + * build is half-done and the publish hangs on an interactive login. + * + * Design: the detection logic (`resolveNpmAuth`) is a PURE function with no network + * and no filesystem access, so it is unit-testable offline. The CLI wrapper reads the + * real environment + `~/.npmrc`, and only AFTER confirming a token exists does it + * optionally probe the network via `npm whoami` (soft — a network failure is a warning, + * never a crash, and never blocks the no-token fail-closed exit). + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken="; + +/** + * Pure auth-detection. No network, no filesystem — everything is passed in. + * + * @param {{ env?: Record, npmrc?: string }} input + * env — a process.env-like object; NPM_TOKEN (or NODE_AUTH_TOKEN) means a token. + * npmrc — the raw text of an .npmrc file (may be empty/undefined). + * @returns {{ ok: boolean, method: ("NPM_TOKEN"|"npmrc-token"|null), why: string }} + */ +export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) { + // 1) Environment token wins — this is how CI and the Bitwarden-token path inject auth. + const envToken = env.NPM_TOKEN || env.NODE_AUTH_TOKEN; + if (typeof envToken === "string" && envToken.trim() !== "") { + return { + ok: true, + method: "NPM_TOKEN", + why: "NPM_TOKEN (or NODE_AUTH_TOKEN) is set in the environment.", + }; + } + + // 2) An //registry.npmjs.org/:_authToken=... line in the npmrc string. + if (typeof npmrc === "string" && npmrc.length > 0) { + const hasAuthToken = npmrc + .split(/\r?\n/) + .map((line) => line.trim()) + .some( + (line) => + !line.startsWith("#") && + !line.startsWith(";") && + line.includes(REGISTRY_AUTH_LINE) && + // require a non-empty value after the '=' + line.slice(line.indexOf(REGISTRY_AUTH_LINE) + REGISTRY_AUTH_LINE.length).trim() !== "" + ); + if (hasAuthToken) { + return { + ok: true, + method: "npmrc-token", + why: `Found '${REGISTRY_AUTH_LINE}...' in the provided .npmrc.`, + }; + } + } + + // 3) Nothing usable. + return { + ok: false, + method: null, + why: "No NPM_TOKEN in the environment and no //registry.npmjs.org/:_authToken line in ~/.npmrc.", + }; +} + +/** Read a file as text, returning "" if it does not exist / is unreadable. */ +function readNpmrc(npmrcPath) { + try { + return fs.readFileSync(npmrcPath, "utf8"); + } catch { + return ""; + } +} + +/** The FAIL banner — documents both remediation paths. Returns the string for testability. */ +function failMessage() { + return [ + "✖ FAIL: npm release-auth preflight — no usable npm auth found.", + " Refusing to start publish work (tag/build/version-sync) with no valid token.", + "", + " Two ways to fix this BEFORE retrying the release:", + "", + " (a) Token path (preferred — non-interactive, no browser):", + " • export NPM_TOKEN= (the operator keeps an npm token in Bitwarden),", + " OR", + " • add this line to ~/.npmrc:", + ` ${REGISTRY_AUTH_LINE}`, + " then re-run: npm run release:preflight", + "", + " (b) Web-auth path (only if no token is available):", + " • run `npm login` in the operator's REAL browser (their own desktop browser,", + " already logged in on their residential IP — not an automated/headless one,", + " which trips Cloudflare/Turnstile). Complete the one-click approval, then", + " re-run: npm run release:preflight", + ].join("\n"); +} + +function main() { + const env = process.env; + const npmrcPath = path.join(os.homedir(), ".npmrc"); + const npmrc = readNpmrc(npmrcPath); + + const result = resolveNpmAuth({ env, npmrc }); + + if (!result.ok) { + // FAIL CLOSED — exit BEFORE any network call (no `npm whoami`), so this can never hang. + console.error(failMessage()); + console.error(`\n detail: ${result.why}`); + process.exit(1); + } + + // A token exists. Report HOW we found it. + const where = + result.method === "NPM_TOKEN" + ? "environment (NPM_TOKEN / NODE_AUTH_TOKEN)" + : `npmrc (${npmrcPath})`; + console.log(`✓ npm token present via ${result.method} — ${where}.`); + + // OPTIONAL network confirmation. Soft: a failure here is a warning, never a crash, + // because the token may be valid even when whoami can't reach the registry (offline, + // proxy, registry blip). The fail-closed guarantee above does not depend on this. + try { + const who = execFileSync("npm", ["whoami"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15000, + }).trim(); + console.log(`✓ PASS: authenticated to npm registry as '${who}'.`); + } catch (err) { + const reason = (err && (err.stderr || err.message) ? String(err.stderr || err.message) : "") + .trim() + .split(/\r?\n/)[0]; + console.warn( + "⚠ WARN: token present but `npm whoami` could not confirm it" + + (reason ? ` (${reason})` : "") + + ".\n Treating as a soft warning — proceeding. If the publish 401s, the token may be expired/invalid." + ); + console.log("✓ PASS (soft): token present; whoami unverified."); + } +} + +// Only run main() when executed directly, so tests can import resolveNpmAuth without +// triggering the CLI side effects (reading ~/.npmrc, process.exit, network). +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/check-npm-auth.test.mjs b/scripts/check-npm-auth.test.mjs new file mode 100644 index 0000000..045e4cc --- /dev/null +++ b/scripts/check-npm-auth.test.mjs @@ -0,0 +1,94 @@ +// Run with: node --test scripts/check-npm-auth.test.mjs +// +// Covers the pure resolveNpmAuth detection (offline, no network) and the CLI +// fail-closed guarantee: the no-token path must exit non-zero WITHOUT touching +// the network (no `npm whoami`), so it can never hang on a publish web-auth wall. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveNpmAuth } from "./check-npm-auth.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT = path.join(__dirname, "check-npm-auth.mjs"); + +test("resolveNpmAuth: NPM_TOKEN present → ok, method NPM_TOKEN", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_abc123" }, npmrc: "" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("resolveNpmAuth: NODE_AUTH_TOKEN present → ok, method NPM_TOKEN", () => { + const r = resolveNpmAuth({ env: { NODE_AUTH_TOKEN: "npm_ci_token" }, npmrc: "" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("resolveNpmAuth: npmrc with _authToken → ok, method npmrc-token", () => { + const npmrc = "//registry.npmjs.org/:_authToken=npm_fromfile\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, true); + assert.equal(r.method, "npmrc-token"); +}); + +test("resolveNpmAuth: neither → not ok, method null", () => { + const r = resolveNpmAuth({ env: {}, npmrc: "" }); + assert.equal(r.ok, false); + assert.equal(r.method, null); +}); + +test("resolveNpmAuth: empty NPM_TOKEN string is not a token", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: " " }, npmrc: "" }); + assert.equal(r.ok, false); + assert.equal(r.method, null); +}); + +test("resolveNpmAuth: commented-out _authToken line does not count", () => { + const npmrc = "# //registry.npmjs.org/:_authToken=npm_disabled\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, false); +}); + +test("resolveNpmAuth: _authToken line with empty value does not count", () => { + const npmrc = "//registry.npmjs.org/:_authToken=\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, false); +}); + +test("resolveNpmAuth: env token wins even when npmrc has none", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_env" }, npmrc: "registry=https://x\n" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("CLI: no-token path exits non-zero with a FAIL message and does not hang", () => { + // Temp HOME with an EMPTY .npmrc, env scrubbed of any npm token. + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-test-")); + fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8"); + + const env = { ...process.env }; + delete env.NPM_TOKEN; + delete env.NODE_AUTH_TOKEN; + env.HOME = tmpHome; + env.USERPROFILE = tmpHome; // Windows-safe, harmless on POSIX + + const res = spawnSync(process.execPath, [SCRIPT], { + env, + encoding: "utf8", + timeout: 20000, // if it ever hangs on network, the test fails loudly instead + }); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + + assert.equal(res.error, undefined, `spawn must not error/timeout: ${res.error}`); + assert.equal(res.status, 1, "no-token CLI must exit 1 (fail closed)"); + const out = `${res.stdout}\n${res.stderr}`; + assert.match(out, /FAIL/, "must print a FAIL message"); + // Prove it exited BEFORE any whoami network probe. + assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami"); +}); From 626f5a0aba789db7c39c02111cb0a003c73fdf81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:10:33 +0800 Subject: [PATCH 2/2] fix(review): robust main() entry guard (pathToFileURL) + stricter npmrc match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review I1/M1/M2 on the npm-auth preflight (#3): - I1: a naive file://${argv[1]} guard silently skipped main() (and the whole auth check) when the install path had spaces or was symlinked — a publish could proceed with NO auth check. Use pathToFileURL(argv[1]).href. - M1: repurpose the previously-unused node:url import. - M2: anchor the npmrc token match with startsWith so a crafted host segment can't false-pass. Adds an I1 regression test (script run from a path with spaces still exits 1). Verified: node --test 10/10; no-token + spaces-path both exit 1. Co-Authored-By: Claude Fable 5 --- scripts/check-npm-auth.mjs | 13 +++++++++---- scripts/check-npm-auth.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/check-npm-auth.mjs b/scripts/check-npm-auth.mjs index 8861642..95f6d95 100644 --- a/scripts/check-npm-auth.mjs +++ b/scripts/check-npm-auth.mjs @@ -19,7 +19,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken="; @@ -51,9 +51,11 @@ export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) { (line) => !line.startsWith("#") && !line.startsWith(";") && - line.includes(REGISTRY_AUTH_LINE) && + // anchor on the line start (already trimmed) so a crafted host segment + // like //evil.com//registry.npmjs.org/:_authToken= can't false-pass. + line.startsWith(REGISTRY_AUTH_LINE) && // require a non-empty value after the '=' - line.slice(line.indexOf(REGISTRY_AUTH_LINE) + REGISTRY_AUTH_LINE.length).trim() !== "" + line.slice(REGISTRY_AUTH_LINE.length).trim() !== "" ); if (hasAuthToken) { return { @@ -150,6 +152,9 @@ function main() { // Only run main() when executed directly, so tests can import resolveNpmAuth without // triggering the CLI side effects (reading ~/.npmrc, process.exit, network). -if (import.meta.url === `file://${process.argv[1]}`) { +// Use pathToFileURL so paths with spaces or symlinked invocations still match — +// a naive `file://${argv[1]}` compare silently skips main() (and thus the whole +// auth check) when the install path contains a space or is a symlink. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } diff --git a/scripts/check-npm-auth.test.mjs b/scripts/check-npm-auth.test.mjs index 045e4cc..d499e9e 100644 --- a/scripts/check-npm-auth.test.mjs +++ b/scripts/check-npm-auth.test.mjs @@ -92,3 +92,28 @@ test("CLI: no-token path exits non-zero with a FAIL message and does not hang", // Prove it exited BEFORE any whoami network probe. assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami"); }); + +test("CLI: still runs main() (fail-closed) when the script path contains spaces (I1 regression)", () => { + // A naive `file://${argv[1]}` entry guard silently SKIPS main() when the path + // has spaces (percent-encoding mismatch) — which would let a publish proceed + // with NO auth check. pathToFileURL() fixes it. Prove main() still fires here. + const spacesDir = fs.mkdtempSync(path.join(os.tmpdir(), "npm auth spaces-")); + const copied = path.join(spacesDir, "check-npm-auth.mjs"); + fs.copyFileSync(SCRIPT, copied); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-home-")); + fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8"); + + const env = { ...process.env }; + delete env.NPM_TOKEN; + delete env.NODE_AUTH_TOKEN; + env.HOME = tmpHome; + env.USERPROFILE = tmpHome; + + const res = spawnSync(process.execPath, [copied], { env, encoding: "utf8", timeout: 20000 }); + + fs.rmSync(spacesDir, { recursive: true, force: true }); + fs.rmSync(tmpHome, { recursive: true, force: true }); + + assert.equal(res.status, 1, "main() must run (exit 1) even from a path containing spaces"); + assert.match(`${res.stdout}\n${res.stderr}`, /FAIL/); +});