Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/bump-homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.12
0.9.13
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
136 changes: 136 additions & 0 deletions scripts/npm-remote.mjs
Original file line number Diff line number Diff line change
@@ -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 <package.tgz> [--json]'); };
Promise.resolve().then(task).catch(error => {
defaultEmit({ type: 'error', message: error.message });
process.exitCode = 1;
});
}
24 changes: 15 additions & 9 deletions tests/commands/keepLockPr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
}
});
});
94 changes: 94 additions & 0 deletions tests/scripts/npmRemoteAuth.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading