mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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>
270 lines
9.6 KiB
JavaScript
Executable File
270 lines
9.6 KiB
JavaScript
Executable File
#!/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));
|
|
}
|