feat+test+docs: D47 — bin/olp-keys.mjs keygen CLI (Phase 2 functional scope closes) (#23)

Fourth Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance
criterion #9 (bootstrap workflow must be reproducible without manual
file editing) by shipping a minimal keygen CLI per § 9.1. Phase 2
functional scope is complete with this D-day — remaining work is
Phase 2 close → v0.2.0 (maintainer-triggered, explicit per
CLAUDE.md release_kit.phase_close_trigger).

NEW bin/olp-keys.mjs (~250 lines): subcommand CLI

  keygen [--owner|--name=X|--tier=guest|owner|--providers=csv|--force]
    Creates a key + prints plaintext token to stdout ONCE; manifest
    stores only SHA-256 hash. --force revokes existing owner keys
    before creating the new owner (ADR § 9.3 recovery flow).

  list [--owner-only|--include-revoked]
    Lists keys with token_hash redacted.

  revoke --id=<key-id>
    Marks the key's revoked_at; idempotent (already-revoked → no-op
    + status message); missing id → exit 2.

  Common flag: --olp-home=<path> overrides ~/.olp/ (defaults to
  OLP_HOME env then ~/.olp/).

package.json bin field

  "bin": { "olp-keys": "./bin/olp-keys.mjs" } so npx olp-keys ...
  resolves. Also "scripts": { "olp-keys": "node bin/olp-keys.mjs" }
  for npm run.

Module shape (testability)

  Exports runCli(argv, { out, err }) so tests invoke with synthetic
  argv + IO writers (no process spawn). Main guard auto-runs when
  invoked as entrypoint.

Plaintext token discipline (ADR § 5 + § 9.1)

  Plaintext printed exactly once on stdout. Never logged, never
  written to manifest, never written to audit. Operators capture
  immediately; lost → --force revoke + regenerate.

--force async correctness

  cmdKeygen is async and awaits each revokeKey (which is async —
  acquires per-key write lock per § 6.4). Sequence: revoke each
  existing owner manifest atomically → then createKey for new
  owner. Avoids race where create-new runs before revoke-old
  completes.

TESTS — Suite 22, +20 (524 → 544):

  22a-1..5: parseArgv unit (--flag=value, --flag value, boolean,
    mixed positional)
  22b-1..5: keygen (owner default, name+providers, missing-name
    error, invalid-tier error, --force revoke-then-create with
    isolation tmpdir)
  22c-1..3: list (empty, populated with token_hash-redaction check,
    --owner-only filter)
  22d-1..4: revoke (valid id, idempotent re-revoke, missing-id
    error, nonexistent-id error)
  22e-1..3: top-level CLI (--help / no args / unknown subcommand
    exit codes)

DOCUMENTATION:

  - AGENTS.md: lib/keys.mjs marker promoted to ; new bin/olp-keys.mjs
    entry. Implementation-status-note + shipped-set updated.
  - README.md: Implementation Status row added for bin/olp-keys.mjs;
    Known limitations note rewritten to "Phase 2 functional scope
    complete; close pending"; new Bootstrap workflow section with
    copy-pasteable npx commands + recovery flow.
  - CHANGELOG.md: D47 entry under Unreleased per release_kit overlay.

AUTHORITY:

  - ADR 0007 — § 5 token format, § 9.1 minimal keygen command
    surface, § 9.3 recovery, § 10 acceptance criterion #9 covered.
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Standing autopilot grant.

Verified: 544/544 pass via npm test (no regression in 524 pre-D47
tests; 20 new Suite 22 tests all green).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-25 14:54:05 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 06f619120d
commit 939f3e6bd9
6 changed files with 561 additions and 5 deletions
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env node
/**
* bin/olp-keys.mjs — OLP key management CLI (Phase 2 / D47)
*
* Authority: ADR 0007 § 9 (Bootstrap & recovery — minimal keygen command
* surface) + § 10 acceptance criterion #9 (bootstrap workflow must be
* reproducible without manual file editing).
*
* Subcommands:
* keygen create a new OLP key; prints plaintext token to stdout ONCE
* list list all keys (manifests with token_hash redacted)
* revoke mark a key as revoked (idempotent; manifest stays for audit)
*
* Usage:
* olp-keys keygen --owner [--name=<label>] [--providers=anthropic,openai,...]
* olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=...]
* olp-keys keygen --owner --force (revokes existing owner keys; new owner)
* olp-keys list [--owner-only] [--include-revoked]
* olp-keys revoke --id=<key-id>
*
* Flags applicable to all subcommands:
* --olp-home=<path> override ~/.olp (defaults to OLP_HOME env or ~/.olp)
* --help print usage and exit 0
*
* Exit codes:
* 0 = success
* 1 = bad usage (missing args, unknown subcommand)
* 2 = operational failure (key not found, manifest invalid, FS error)
*
* The plaintext token from `keygen` is printed exactly once to stdout. It is
* never written to manifest, audit, or any log line. Operators must capture
* it immediately; lost → revoke + regenerate. Per ADR 0007 § 5 + § 9.1.
*/
import {
createKey,
listKeys,
revokeKey,
readManifest,
} from '../lib/keys.mjs';
// ── Arg parsing ───────────────────────────────────────────────────────────
/**
* Minimal flag parser. Supports:
* --flag=value → { flag: 'value' }
* --flag value → { flag: 'value' } (if next arg doesn't start with --)
* --flag → { flag: true }
* Returns { positional: string[], flags: Record<string, string|true> }.
*/
export function parseArgv(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq > 0) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const name = arg.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
} else {
flags[name] = true;
}
}
} else {
positional.push(arg);
}
}
return { positional, flags };
}
const USAGE = `OLP key management CLI
Usage:
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
olp-keys list [--owner-only] [--include-revoked]
olp-keys revoke --id=<key-id>
Common flags:
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
--help Print this message
Authority: ADR 0007 § 9 (bootstrap & recovery).`;
// ── Subcommand implementations ────────────────────────────────────────────
async function cmdKeygen(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const owner = flags.owner === true;
const force = flags.force === true;
let tier = flags.tier;
if (owner) tier = 'owner';
if (!tier) tier = 'guest';
if (tier !== 'owner' && tier !== 'guest') {
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
return 1;
}
const name = flags.name || (owner ? 'owner' : null);
if (!name) {
ioErr('Error: --name is required (or use --owner to default to "owner").\n');
return 1;
}
const providersFlag = flags.providers;
let providers_enabled;
if (providersFlag === undefined || providersFlag === true) {
providers_enabled = '*';
} else if (typeof providersFlag === 'string') {
providers_enabled = providersFlag.split(',').map(s => s.trim()).filter(Boolean);
if (providers_enabled.length === 0) providers_enabled = '*';
} else {
providers_enabled = '*';
}
// --force: revoke any existing owner keys before creating the new one.
// revokeKey is async (acquires per-key write lock); await each so the new
// owner key's createKey doesn't race the revoke writes.
if (force && tier === 'owner') {
const existing = listKeys({ olpHome });
for (const m of existing) {
if (m.owner_tier === 'owner' && m.revoked_at === null) {
try {
await revokeKey({ id: m.id, olpHome });
ioErr(`Revoked existing owner key id=${m.id} name="${m.name}" (--force).\n`);
} catch (err) {
ioErr(`Warning: failed to revoke existing owner key id=${m.id}: ${err?.message ?? err}\n`);
}
}
}
}
let result;
try {
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
} catch (err) {
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
return 2;
}
// Plaintext token — printed ONCE per ADR § 5 + § 9.1.
ioOut(`\n OLP key created — capture the plaintext token NOW; it will not be shown again.\n\n`);
ioOut(` id: ${result.id}\n`);
ioOut(` name: ${result.manifest.name}\n`);
ioOut(` owner_tier: ${result.manifest.owner_tier}\n`);
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
ioOut(` created_at: ${result.manifest.created_at}\n`);
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
return 0;
}
function cmdList(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const ownerOnly = flags['owner-only'] === true;
const includeRevoked = flags['include-revoked'] === true;
let keys = listKeys({ olpHome });
if (ownerOnly) keys = keys.filter(k => k.owner_tier === 'owner');
if (!includeRevoked) keys = keys.filter(k => k.revoked_at === null);
if (keys.length === 0) {
ioOut('No keys.\n');
return 0;
}
ioOut(`\n ${keys.length} key${keys.length === 1 ? '' : 's'}:\n\n`);
for (const k of keys) {
const providers = typeof k.providers_enabled === 'string'
? k.providers_enabled
: `[${k.providers_enabled.join(', ')}]`;
const status = k.revoked_at === null ? 'active' : `revoked (${k.revoked_at})`;
const lastUsed = k.last_used_at ?? 'never';
ioOut(` id=${k.id}\n`);
ioOut(` name: ${k.name}\n`);
ioOut(` owner_tier: ${k.owner_tier}\n`);
ioOut(` providers: ${providers}\n`);
ioOut(` status: ${status}\n`);
ioOut(` created: ${k.created_at}\n`);
ioOut(` last_used: ${lastUsed}\n`);
if (k.notes) ioOut(` notes: ${k.notes}\n`);
ioOut('\n');
}
return 0;
}
async function cmdRevoke(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const id = typeof flags.id === 'string' ? flags.id : null;
if (!id) {
ioErr('Error: --id=<key-id> is required.\n');
return 1;
}
// Confirm the key exists before attempting revoke (clearer error path).
const m = readManifest(id, { olpHome });
if (m === null) {
ioErr(`Error: no key with id="${id}".\n`);
return 2;
}
if (m.revoked_at !== null) {
ioOut(`Key id=${id} already revoked at ${m.revoked_at} (no-op).\n`);
return 0;
}
try {
await revokeKey({ id, olpHome });
} catch (err) {
ioErr(`Error: revokeKey failed: ${err?.message ?? err}\n`);
return 2;
}
ioOut(`Revoked key id=${id} name="${m.name}".\n`);
return 0;
}
// ── CLI entry ────────────────────────────────────────────────────────────
/**
* Run the CLI with explicit argv + IO streams. Returns the intended exit code.
* Exported for tests (no process.exit, no direct stdout/stderr).
*
* @param {string[]} argv - args AFTER the subcommand name (e.g., ['keygen', '--owner']).
* The first element is the subcommand.
* @param {object} [opts]
* @param {(s: string) => void} [opts.out] - stdout writer; defaults to process.stdout.write
* @param {(s: string) => void} [opts.err] - stderr writer; defaults to process.stderr.write
* @returns {Promise<number>} exit code 0 / 1 / 2
*/
export async function runCli(argv, opts = {}) {
const ioOut = opts.out ?? (s => process.stdout.write(s));
const ioErr = opts.err ?? (s => process.stderr.write(s));
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
ioOut(USAGE + '\n');
return argv.length === 0 ? 1 : 0;
}
const [subcommand, ...rest] = argv;
const { flags } = parseArgv(rest);
switch (subcommand) {
case 'keygen': return await cmdKeygen(flags, ioOut, ioErr);
case 'list': return cmdList(flags, ioOut, ioErr);
case 'revoke': return await cmdRevoke(flags, ioOut, ioErr);
default:
ioErr(`Error: unknown subcommand "${subcommand}".\n${USAGE}\n`);
return 1;
}
}
// Main guard: only run when invoked as the entrypoint. ESM equivalent of
// `require.main === module` is comparing import.meta.url against argv[1].
const isMain = (() => {
try {
return import.meta.url === `file://${process.argv[1]}`;
} catch {
return false;
}
})();
if (isMain) {
runCli(process.argv.slice(2)).then(code => process.exit(code));
}