diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index e9ef823e..add58865 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -13,7 +13,7 @@ jobs: bump: # Skip pre-releases — they shouldn't push to the stable tap. if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false - runs-on: latchkey-small + runs-on: ubuntu-latest steps: # npm sometimes lags a release event by a few seconds. Give it a # breather before brew bump-formula-pr fetches the tarball. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0283819..aa40abfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Test run: bun run test + - name: Release authentication tests + run: bun run test:release + # Embedded browser screens: dependency freeze + no external URLs + # __CAPY_DATA__ placeholders intact. - name: Screen checks diff --git a/README.md b/README.md index bc7a4c86..ef1f5a17 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,34 @@ You can fork this repo and create pull requests: [github.com/capysc/capy-cli](https://github.com/capysc/capy-cli) - [bugs](https://github.com/capysc/capy-cli/issues) and [discussions](https://github.com/capysc/capy-cli/discussions) +### Publishing from a remote agent + +Publish npm releases only from a verified commit on `origin/main`, after the +release PR has been merged and full CI for that exact commit has passed. Build +and pack from that commit; a detached worktree keeps `main` unchanged. Confirm +the product changes against the previous release before tagging or publishing. +Never publish a tarball built from a feature or release branch. + +Maintainers can approve npm authentication on another device without a callback +to the publishing machine. Use npm 11.9 or newer with a supported Node version: + +```sh +npm run release:login +npm run release:publish -- /absolute/path/to/reviewed-package.tgz +``` + +The login command prints npm's browser link and waits for npm's registry to +confirm approval. Publishing accepts a previously built and reviewed `npm pack` +artifact. If npm asks for another security-key approval, the helper prints its +link, polls the registry, and retries that same artifact once. It does not launch +a local browser or require an inbound callback. Complete approval within five +minutes; expired approvals require a fresh command. + +Add `--json` to either command for newline-delimited status and approval events. +Only the browser approval link is emitted; registry polling URLs and one-time +tokens stay within the publishing process. Login credentials are saved by npm +in its normal user configuration. + ## License AGPL-3.0-only. Copyright © Incentv Technologies Inc. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 063cded2..5864d35b 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -58,7 +58,7 @@ Generated by `scripts/gen-third-party-notices.mjs` from the esbuild bundle — | signal-exit | 4.1.0 | ISC | | string-width | 4.2.3 | MIT | | strip-ansi | 6.0.1 | MIT | -| undici | 6.29.0 | MIT | +| undici | 6.29.1 | MIT | | wrap-ansi | 6.2.0 | MIT | | yoctocolors-cjs | 2.1.3 | MIT | @@ -1458,7 +1458,7 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### undici@6.29.0 +### undici@6.29.1 https://undici.nodejs.org diff --git a/VERSION b/VERSION index 583b27ac..62ea2590 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.12 +0.9.13 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 94f9aac0..ec960961 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.12`. Generated from `capy help --json`. +Version `0.9.13`. Generated from `capy help --json`. ## Commands diff --git a/package.json b/package.json index ecdebd9f..cff19a88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.12", + "version": "0.9.13", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { @@ -36,6 +36,9 @@ "preuninstall": "capy cleanup 2>/dev/null || true", "prepublishOnly": "npm pkg delete bin.capy-dev bin.capy-staging", "test": "bash tests/run-tests.sh", + "test:release": "node --test tests/scripts/npmRemoteAuth.test.mjs", + "release:login": "node scripts/npm-remote.mjs login", + "release:publish": "node scripts/npm-remote.mjs publish", "typecheck": "tsc --noEmit", "test:e2e": "bun test tests/e2e/", "test:plugins": "bash tests/plugins/run-plugin-tests.sh", diff --git a/scripts/npm-remote.mjs b/scripts/npm-remote.mjs new file mode 100644 index 00000000..131b3090 --- /dev/null +++ b/scripts/npm-remote.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Browser approval happens on npmjs.com; completion is polled from the registry. +import { execFile, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createInterface } from 'node:readline'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { setTimeout as delay } from 'node:timers/promises'; + +const registry = 'https://registry.npmjs.org/'; +const exec = promisify(execFile); + +export function validateApproval(authUrl, doneUrl) { + const auth = new URL(authUrl); + const done = new URL(doneUrl); + if (auth.origin !== 'https://www.npmjs.com' || done.origin !== new URL(registry).origin + || auth.username || auth.password || done.username || done.password) { + throw new Error('Refusing an approval URL outside the official npm service.'); + } + return Object.freeze({ authUrl: auth.href, doneUrl: done.href }); +} + +export async function pollApproval(doneUrl, { + fetchImpl = fetch, + sleep = delay, + now = Date.now, + timeoutMs = 5 * 60_000, +} = {}) { + const deadline = now() + timeoutMs; + const poll = async () => { + const remaining = deadline - now(); + if (remaining <= 0) throw new Error('npm approval expired. Run the command again for a fresh link.'); + const response = await fetchImpl(doneUrl, { + redirect: 'error', + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(remaining), + }); + if (response.status === 200) { + const result = await response.json(); + if (typeof result.token !== 'string' || result.token.length === 0) { + throw new Error('npm approval completed without a token.'); + } + return result.token; + } + if (response.status !== 202) { + throw new Error(`npm approval failed (HTTP ${response.status}). Request a fresh approval link.`); + } + const retry = Number(response.headers.get('retry-after')); + const wait = Number.isFinite(retry) && retry > 0 ? Math.min(retry * 1000, 30_000) : 2000; + await sleep(Math.min(wait, Math.max(0, deadline - now()))); + return poll(); + }; + return poll(); +} + +export async function runNpm(args, otp) { + try { + const result = await exec('npm', [...args, `--registry=${registry}`], { + env: { ...process.env, ...(otp ? { npm_config_otp: otp } : {}) }, + maxBuffer: 4 * 1024 * 1024, + }); + return Object.freeze({ ok: true, stdout: result.stdout }); + } catch (error) { + if (error.code === 'ENOENT') throw new Error('npm is required.'); + return Object.freeze({ ok: false, stdout: error.stdout ?? '' }); + } +} + +export async function login({ emit = defaultEmit, spawnImpl = spawn } = {}) { + emit({ type: 'status', message: 'Waiting for npm login; no callback to this machine is needed.' }); + const child = spawnImpl('npm', ['login', '--auth-type=web', '--browser=false', `--registry=${registry}`], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const completed = once(child, 'close'); + const forward = async (stream) => { + for await (const line of createInterface({ input: stream })) { + const url = line.match(/^https:\/\/www\.npmjs\.com\/\S+$/)?.[0]; + if (url) emit({ type: 'approval', purpose: 'login', url }); + } + }; + const [[code]] = await Promise.all([completed, forward(child.stdout), forward(child.stderr)]); + if (code !== 0) throw new Error('npm login did not complete. Run the login command again for a fresh link.'); + emit({ type: 'authenticated' }); +} + +export async function publish(tarball, { + run = runNpm, + signIn = login, + poll = pollApproval, + emit = defaultEmit, +} = {}) { + if (!tarball.endsWith('.tgz')) throw new Error('Publish a reviewed npm pack .tgz artifact.'); + const identity = await run(['whoami', '--json']); + if (!identity.ok) await signIn({ emit }); + const args = ['publish', resolve(tarball), '--access=public', '--tag=latest', '--ignore-scripts', '--browser=false', '--json']; + const first = await run(args); + if (first.ok) { + emit({ type: 'published', artifact: resolve(tarball) }); + return; + } + const parsed = (() => { + try { return JSON.parse(first.stdout); } catch { return {}; } + })(); + const error = parsed.error ?? parsed; + if (error.code !== 'EOTP' || !error.authUrl || !error.doneUrl) { + throw new Error(`npm publish failed (${error.code ?? 'unknown error'}). No publish retry was attempted.`); + } + const approval = validateApproval(error.authUrl, error.doneUrl); + emit({ type: 'approval', purpose: 'publish', url: approval.authUrl }); + const otp = await poll(approval.doneUrl); + const approved = await run(args, otp); + if (!approved.ok) throw new Error('npm publish failed after approval. Check the registry before retrying.'); + emit({ type: 'published', artifact: resolve(tarball) }); +} + +const defaultEmit = (event) => { + if (process.argv.includes('--json')) { + process.stdout.write(`${JSON.stringify(event)}\n`); + } else { + process.stdout.write(`${event.type === 'approval' ? `Approve npm ${event.purpose}: ${event.url}` : event.message ?? event.type}\n`); + } +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2).filter(arg => arg !== '--json'); + const task = args[0] === 'login' && args.length === 1 + ? () => login() + : args[0] === 'publish' && args.length === 2 + ? () => publish(args[1]) + : () => { throw new Error('Usage: node scripts/npm-remote.mjs login | publish [--json]'); }; + Promise.resolve().then(task).catch(error => { + defaultEmit({ type: 'error', message: error.message }); + process.exitCode = 1; + }); +} diff --git a/tests/commands/keepLockPr.test.ts b/tests/commands/keepLockPr.test.ts index d1d06b86..808cee07 100644 --- a/tests/commands/keepLockPr.test.ts +++ b/tests/commands/keepLockPr.test.ts @@ -7,6 +7,7 @@ * `mock.module` — so this file needs no process isolation. */ import { describe, test, expect, mock, spyOn } from 'bun:test'; +import { PassThrough } from 'node:stream'; import { buildCommitMessage, buildPrBody, @@ -610,11 +611,18 @@ describe('PR branch name', () => { describe('after the edit TUI exits, before the PR prompts', () => { test('raw mode is off and the TUI key listener is gone when the confirm runs', async () => { - const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); - const originalRaw = Object.getOwnPropertyDescriptor(process.stdin, 'setRawMode'); - const setRawMode = mock((_on: boolean) => process.stdin); - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); - Object.defineProperty(process.stdin, 'setRawMode', { value: setRawMode, configurable: true, writable: true }); + const originalStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); + if (originalStdin === undefined) throw new Error('process.stdin must exist'); + // Earlier screens can leave the runner's non-TTY stdin closed. Marking that + // stream as a TTY cannot reopen it: Bun's pause() is then a no-op. A fresh + // stream exercises the real screen's cleanup independently of test order. + const input = new PassThrough(); + const setRawMode = mock((_on: boolean) => input); + Object.defineProperties(input, { + isTTY: { value: true }, + setRawMode: { value: setRawMode }, + }); + Object.defineProperty(process, 'stdin', { value: input, configurable: true }); const write = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); const dataListenersBefore = process.stdin.listenerCount('data'); const resizeListenersBefore = process.listenerCount('SIGWINCH'); @@ -658,10 +666,8 @@ describe('after the edit TUI exits, before the PR prompts', () => { expect(process.listenerCount('SIGWINCH')).toBe(resizeListenersBefore); } finally { write.mockRestore(); - if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); - else Reflect.deleteProperty(process.stdin, 'isTTY'); - if (originalRaw) Object.defineProperty(process.stdin, 'setRawMode', originalRaw); - else Reflect.deleteProperty(process.stdin, 'setRawMode'); + Object.defineProperty(process, 'stdin', originalStdin); + input.destroy(); } }); }); diff --git a/tests/scripts/npmRemoteAuth.test.mjs b/tests/scripts/npmRemoteAuth.test.mjs new file mode 100644 index 00000000..06e80faa --- /dev/null +++ b/tests/scripts/npmRemoteAuth.test.mjs @@ -0,0 +1,94 @@ +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { login, pollApproval, publish, validateApproval } from '../../scripts/npm-remote.mjs'; + +const authUrl = 'https://www.npmjs.com/auth/cli/test'; +const doneUrl = 'https://registry.npmjs.org/-/v1/done/test'; +const challenge = { ok: false, stdout: JSON.stringify({ error: { code: 'EOTP', authUrl, doneUrl } }) }; + +test('login streams the remote approval URL without a TTY or local browser', async () => { + const child = new class extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + }(); + const emit = mock.fn(); + const spawnImpl = mock.fn(() => { + setImmediate(() => { + child.stdout.end('Login at:\nhttps://www.npmjs.com/login?next=/login/cli/test\n'); + child.stderr.end(); + child.emit('close', 0); + }); + return child; + }); + await login({ emit, spawnImpl }); + assert.deepEqual(spawnImpl.mock.calls[0].arguments[2].stdio, ['ignore', 'pipe', 'pipe']); + assert.ok(spawnImpl.mock.calls[0].arguments[1].includes('--browser=false')); + assert.equal(emit.mock.calls[1].arguments[0].url, 'https://www.npmjs.com/login?next=/login/cli/test'); + assert.equal(emit.mock.calls[2].arguments[0].type, 'authenticated'); +}); + +test('rejects approval URLs that could disclose credentials to another origin', () => { + assert.throws(() => validateApproval('https://evil.example/auth', doneUrl)); + assert.throws(() => validateApproval(authUrl, 'https://evil.example/token')); + assert.throws(() => validateApproval(authUrl, 'http://registry.npmjs.org/token')); + assert.throws(() => validateApproval('https://user:pass@www.npmjs.com/auth', doneUrl)); +}); + +test('polls pending approval, respects retry-after, and returns the token only to its caller', async () => { + const fetchImpl = mock.fn(async () => fetchImpl.mock.callCount() === 0 + ? new Response('{}', { status: 202, headers: { 'retry-after': '3' } }) + : new Response('{"token":"one-time-secret"}')); + const sleep = mock.fn(async () => {}); + assert.equal(await pollApproval(doneUrl, { fetchImpl, sleep }), 'one-time-secret'); + assert.equal(sleep.mock.calls[0].arguments[0], 3000); + assert.equal(fetchImpl.mock.calls[0].arguments[1].redirect, 'error'); +}); + +test('expires an unfinished approval and rejects expired or malformed results', async () => { + await assert.rejects(pollApproval(doneUrl, { timeoutMs: 0 }), /expired/); + await assert.rejects(pollApproval(doneUrl, { fetchImpl: async () => new Response('{}', { status: 410 }) }), /HTTP 410/); + await assert.rejects(pollApproval(doneUrl, { fetchImpl: async () => new Response('{}') }), /without a token/); +}); + +test('non-TTY publishing relays approval and retries the same artifact exactly once', async () => { + const run = mock.fn(async () => run.mock.callCount() === 1 ? challenge : { ok: true, stdout: '{}' }); + const emit = mock.fn(); + const poll = mock.fn(async () => 'one-time-secret'); + await publish('release.tgz', { run, emit, poll }); + assert.equal(run.mock.callCount(), 3); + assert.deepEqual(run.mock.calls[1].arguments[0], run.mock.calls[2].arguments[0]); + assert.equal(run.mock.calls[2].arguments[1], 'one-time-secret'); + assert.deepEqual(emit.mock.calls[0].arguments[0], { type: 'approval', purpose: 'publish', url: authUrl }); + assert.equal(emit.mock.calls[1].arguments[0].type, 'published'); + assert.doesNotMatch(JSON.stringify(emit.mock.calls), /one-time-secret|done\/test/); +}); + +test('requests login before publishing when the stored npm session is expired', async () => { + const run = mock.fn(async () => ({ ok: run.mock.callCount() !== 0, stdout: '{}' })); + const signIn = mock.fn(async () => {}); + await publish('release.tgz', { run, signIn, emit: () => {} }); + assert.equal(signIn.mock.callCount(), 1); + assert.equal(run.mock.callCount(), 2); +}); + +test('does not retry ordinary publish errors or poll an untrusted challenge', async () => { + const denied = mock.fn(async () => denied.mock.callCount() === 0 ? { ok: true } : { ok: false, stdout: '{"error":{"code":"E403"}}' }); + await assert.rejects(publish('release.tgz', { run: denied, emit: () => {} }), /E403/); + assert.equal(denied.mock.callCount(), 2); + const run = mock.fn(async () => run.mock.callCount() === 0 ? { ok: true } : { + ok: false, stdout: JSON.stringify({ error: { code: 'EOTP', authUrl, doneUrl: 'https://evil.example/token' } }), + }); + const poll = mock.fn(); + await assert.rejects(publish('release.tgz', { run, poll, emit: () => {} }), /official npm/); + assert.equal(poll.mock.callCount(), 0); +}); + +test('does not announce success or repeat approval when the approved publish fails', async () => { + const run = mock.fn(async () => run.mock.callCount() === 0 ? { ok: true } : challenge); + const emit = mock.fn(); + await assert.rejects(publish('release.tgz', { run, emit, poll: async () => 'secret' }), /failed after approval/); + assert.equal(run.mock.callCount(), 3); + assert.equal(emit.mock.callCount(), 1); +});