diff --git a/bin/olp.mjs b/bin/olp.mjs new file mode 100755 index 0000000..47b18c4 --- /dev/null +++ b/bin/olp.mjs @@ -0,0 +1,734 @@ +#!/usr/bin/env node +/** + * bin/olp.mjs — OLP operator CLI (Phase 4 / D64) + * + * Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper + * (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the + * python3 JSON-parsing fragility called out in the ADR. + * + * Subcommands: + * status GET /v0/management/status (owner-only) + * health GET /health + * usage GET /v0/management/dashboard-data (owner-only) + * models GET /v1/models + * cache GET /cache/stats (owner-only) + * providers local: models-registry + config providers.enabled + * chain show [] local: ~/.olp/config.json routing.chains + * logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query + * restart launchctl (macOS) / systemctl --user (Linux) + * doctor [--check X] run lib/doctor.mjs runDoctor + format + * keys ... delegate to bin/olp-keys.mjs + * help | --help usage + * + * Global flags: + * --json emit raw JSON (silences human-readable output) + * --proxy-url= override resolved proxy URL + * --olp-home= override ~/.olp + * + * URL resolution: + * OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567} + * + * Auth (Bearer token) resolution: + * 1. OLP_API_KEY env + * 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4) + * 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk + * + * Note: the third option only works during the same session in which `olp-keys + * keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN. + * listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore + * reports "no owner token available" + remediation instructions if env vars are + * absent — it does NOT try to crack the hash. + * + * Exit codes: + * 0 = success + * 1 = bad usage / unknown subcommand + * 2 = network or HTTP error (4xx/5xx) + * 3 = auth missing / forbidden + */ + +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { URL } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { spawn as spawnProc } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { realpathSync } from 'node:fs'; + +import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs'; +import { listKeys } from '../lib/keys.mjs'; +import { readAuditWindow } from '../lib/audit-query.mjs'; +import modelsRegistry from '../models-registry.json' with { type: 'json' }; +import { runCli as runKeysCli } from './olp-keys.mjs'; + +// ── ANSI helpers (no chalk dep) ─────────────────────────────────────────── + +const ANSI = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +}; + +function colorize(s, code, useColor) { + if (!useColor) return s; + return `${code}${s}${ANSI.reset}`; +} + +function statusBadge(status, useColor) { + const map = { + ok: { txt: 'PASS', col: ANSI.green }, + warn: { txt: 'WARN', col: ANSI.yellow }, + fail: { txt: 'FAIL', col: ANSI.red }, + }; + const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray }; + return colorize(`[${m.txt}]`, m.col, useColor); +} + +// ── Arg parser (mirror bin/olp-keys.mjs shape) ──────────────────────────── + +export function parseArgv(argv) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const eq = a.indexOf('='); + if (eq > 0) { + flags[a.slice(2, eq)] = a.slice(eq + 1); + } else { + const name = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[name] = next; + i++; + } else { + flags[name] = true; + } + } + } else { + positional.push(a); + } + } + return { positional, flags }; +} + +// ── Output helpers (respect --json) ─────────────────────────────────────── + +function makeIO(opts) { + const out = opts.out ?? (s => process.stdout.write(s)); + const err = opts.err ?? (s => process.stderr.write(s)); + const wantJson = opts.json === true; + // When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op + // (debug noise suppression per the bundle requirements). `errln` always writes + // to stderr. + const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); }; + const errln = (...parts) => err(parts.join(' ') + '\n'); + const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n'); + return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) }; +} + +// ── HTTP helper ─────────────────────────────────────────────────────────── + +async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) { + return new Promise(resolve => { + let done = false; + const finish = (v) => { if (!done) { done = true; resolve(v); } }; + let urlObj; + try { urlObj = new URL(url); } + catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); } + const isHttps = urlObj.protocol === 'https:'; + const reqFn = isHttps ? httpsRequest : httpRequest; + let req; + try { + req = reqFn(url, { method, headers, timeout: timeoutMs }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers })); + }); + } catch (e) { + return finish({ ok: false, error: String(e?.message ?? e) }); + } + req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code })); + req.on('timeout', () => { + try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ } + }); + req.end(); + }); +} + +// ── Token resolution ────────────────────────────────────────────────────── + +/** + * Resolve a Bearer token. Returns the plaintext token string or null. + * Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed). + */ +export function resolveBearerToken() { + if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY; + if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN; + return null; +} + +function authHeaders() { + const tok = resolveBearerToken(); + return tok ? { Authorization: `Bearer ${tok}` } : {}; +} + +// ── HTTP-error → exit code mapping ──────────────────────────────────────── + +function httpErrorToExit(res, io) { + if (!res.ok) { + if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) { + io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`); + io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`); + return 2; + } + io.errln(`Error: network error: ${res.error}`); + return 2; + } + if (res.status === 401) { + io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`); + io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`); + return 3; + } + if (res.status === 403) { + io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`); + return 3; + } + if (res.status >= 400) { + io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`); + return 2; + } + return 0; +} + +// ── Human-readable formatters ───────────────────────────────────────────── + +function formatBytes(n) { + if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`; + return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`; +} + +function formatMs(ms) { + if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?'; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`; + return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`; +} + +// ── Subcommand: status ──────────────────────────────────────────────────── + +async function cmdStatus(flags, io) { + const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() }); + const ec = httpErrorToExit(res, io); + if (ec !== 0) return ec; + let body; + try { body = JSON.parse(res.body); } + catch { io.errln('Error: server returned non-JSON body'); return 2; } + if (io.wantJson) { io.emitJson(body); return 0; } + io.log(colorize('OLP status', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + io.log(` version: ${body.version}`); + io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`); + io.log(` started: ${body.started_at}`); + io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`); + if (body.providers?.status && typeof body.providers.status === 'object') { + for (const [name, s] of Object.entries(body.providers.status)) { + const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor); + io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`); + } + } + io.log(` total reqs: ${body.stats?.total_requests ?? 0}`); + io.log(` active reqs: ${body.stats?.active_requests ?? 0}`); + if (body.stats?.cache) { + const c = body.stats.cache; + io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`); + } + if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) { + io.log(` recent errors: ${body.recent_errors.length}`); + for (const e of body.recent_errors.slice(0, 5)) { + io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'} — ${(e.message ?? '').slice(0, 80)}`); + } + } + return 0; +} + +// ── Subcommand: health ──────────────────────────────────────────────────── + +async function cmdHealth(flags, io) { + const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const res = await httpFetch(`${url}/health`, { headers: authHeaders() }); + const ec = httpErrorToExit(res, io); + if (ec !== 0) return ec; + let body; + try { body = JSON.parse(res.body); } + catch { io.errln('Error: server returned non-JSON body'); return 2; } + if (io.wantJson) { io.emitJson(body); return 0; } + io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const [k, v] of Object.entries(body)) { + if (typeof v === 'object' && v !== null) { + io.log(` ${k}:`); + for (const [k2, v2] of Object.entries(v)) { + io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`); + } + } else { + io.log(` ${k}: ${v}`); + } + } + return 0; +} + +// ── Subcommand: usage ───────────────────────────────────────────────────── + +async function cmdUsage(flags, io) { + const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() }); + const ec = httpErrorToExit(res, io); + if (ec !== 0) return ec; + let body; + try { body = JSON.parse(res.body); } + catch { io.errln('Error: server returned non-JSON body'); return 2; } + if (io.wantJson) { io.emitJson(body); return 0; } + io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + const u24 = body.usage_24h ?? body.usage24h ?? body['24h'] ?? {}; + if (typeof u24 === 'object' && Object.keys(u24).length > 0) { + io.log(` requests: ${u24.requests ?? '?'}`); + io.log(` cache hits: ${u24.cache_hits ?? '?'}`); + io.log(` fallbacks: ${u24.fallbacks ?? '?'}`); + } else { + io.log(' (no 24h usage data — server may not have processed any requests yet)'); + } + if (Array.isArray(body.providers)) { + io.log(''); + io.log(colorize('Per-provider quota', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const p of body.providers) { + io.log(` ${String(p.name ?? '?').padEnd(12)} ${p.percent_used != null ? `${p.percent_used}% used` : 'no quota api'}`); + } + } + if (Array.isArray(body.top_fallback_chains)) { + io.log(''); + io.log(colorize('Top fallback chains', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const f of body.top_fallback_chains.slice(0, 10)) { + io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`); + } + } + return 0; +} + +// ── Subcommand: models ──────────────────────────────────────────────────── + +async function cmdModels(flags, io) { + const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() }); + const ec = httpErrorToExit(res, io); + if (ec !== 0) return ec; + let body; + try { body = JSON.parse(res.body); } + catch { io.errln('Error: server returned non-JSON body'); return 2; } + if (io.wantJson) { io.emitJson(body); return 0; } + io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const m of body.data ?? []) { + io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`); + } + return 0; +} + +// ── Subcommand: cache ───────────────────────────────────────────────────── + +async function cmdCache(flags, io) { + const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() }); + const ec = httpErrorToExit(res, io); + if (ec !== 0) return ec; + let body; + try { body = JSON.parse(res.body); } + catch { io.errln('Error: server returned non-JSON body'); return 2; } + if (io.wantJson) { io.emitJson(body); return 0; } + io.log(colorize('OLP cache', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + io.log(` entries: ${body.entries ?? '?'}`); + io.log(` hits: ${body.hits ?? 0}`); + io.log(` misses: ${body.misses ?? 0}`); + io.log(` evictions:${body.evictions ?? 0}`); + io.log(` bytes: ${formatBytes(body.bytes ?? 0)} (max ${formatBytes(body.maxBytes ?? 0)})`); + return 0; +} + +// ── Subcommand: providers (local) ───────────────────────────────────────── + +function cmdProviders(flags, io) { + const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] }); + const configPath = join(olpHome, 'config.json'); + let enabled = {}; + try { + const cfg = JSON.parse(readFileSync(configPath, 'utf8')); + enabled = cfg?.providers?.enabled ?? {}; + } catch { /* fine — empty enabled map */ } + + const providers = modelsRegistry?.providers ?? {}; + const rows = []; + for (const [name, p] of Object.entries(providers)) { + rows.push({ + name, + displayName: p?.displayName ?? name, + tier: p?.tier ?? '?', + modelCount: (p?.models ?? []).length, + enabled: enabled[name] === true, + candidate: p?.candidate === true, + }); + } + if (io.wantJson) { + io.emitJson({ providers: rows, config_path: configPath }); + return 0; + } + io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const r of rows) { + const enabledTxt = r.enabled + ? colorize('enabled ', ANSI.green, io.useColor) + : colorize('disabled', ANSI.gray, io.useColor); + const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : ''; + io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`); + } + io.log(''); + io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor)); + return 0; +} + +// ── Subcommand: chain show ──────────────────────────────────────────────── + +function cmdChainShow(positional, flags, io) { + const target = positional[0] ?? null; // model name, or null = print all + const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] }); + const configPath = join(olpHome, 'config.json'); + let chains = {}; + try { + const cfg = JSON.parse(readFileSync(configPath, 'utf8')); + chains = cfg?.routing?.chains ?? {}; + } catch { /* empty */ } + + if (io.wantJson) { + if (target) { + io.emitJson({ model: target, chain: chains[target] ?? null }); + } else { + io.emitJson({ chains }); + } + return 0; + } + io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + if (Object.keys(chains).length === 0) { + io.log(` (no chains configured in ${configPath})`); + return 0; + } + if (target) { + const chain = chains[target]; + if (!chain) { + io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`); + return 1; + } + io.log(` ${target}:`); + for (const hop of chain) { + io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`); + } + } else { + for (const [model, chain] of Object.entries(chains)) { + io.log(` ${model}:`); + for (const hop of chain) { + io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`); + } + } + } + return 0; +} + +// ── Subcommand: logs ────────────────────────────────────────────────────── + +async function cmdLogs(positional, flags, io) { + const n = positional[0] ? parseInt(positional[0], 10) : 20; + if (!Number.isFinite(n) || n <= 0) { + io.errln(`Error: invalid log count "${positional[0]}"`); + return 1; + } + const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] }); + // readAuditWindow is a generator over [startMs, endMs). Default window = last 24h. + const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000; + const endMs = Date.now(); + const startMs = endMs - windowMs; + let events = []; + try { + for (const ev of readAuditWindow({ startMs, endMs, olpHome })) { + events.push(ev); + } + } catch (e) { + io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`); + return 2; + } + let filtered = events; + if (flags.level) { + filtered = filtered.filter(e => e.level === flags.level); + } + // Tail (audit events are already chronological per generator order). + filtered = filtered.slice(-n); + if (io.wantJson) { + io.emitJson({ events: filtered }); + return 0; + } + io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const e of filtered) { + // Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is + // not always present in audit ndjson (it is in stderr-side logEvent). + const level = (e.level ?? 'info').toUpperCase(); + const levelColor = + level === 'ERROR' ? ANSI.red + : level === 'WARN' ? ANSI.yellow + : ANSI.gray; + const summary = e.message ?? e.error ?? ''; + io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`); + } + return 0; +} + +// ── Subcommand: restart ─────────────────────────────────────────────────── + +async function cmdRestart(flags, io) { + // macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy + // Linux: systemctl --user restart olp-proxy + // Neither installed → fall through to a helpful error. + // + // CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per + // ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k` + // does NOT re-read the plist's EnvironmentVariables block — launchd + // sticks to its cached env from the most recent bootstrap. If you edited + // ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will + // silently use stale values. Use `launchctl bootout gui//dev.olp.proxy` + // followed by `launchctl bootstrap gui/ ~/Library/LaunchAgents/dev.olp.proxy.plist` + // to force a clean env reload. The Phase 4 installer (planned post-D73) + // will expose `olp restart --full` for the bootout/bootstrap dance. + const platform = process.platform; + const uid = process.getuid?.() ?? null; + let cmd, args; + if (platform === 'darwin') { + if (uid == null) { + io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl'); + return 2; + } + cmd = 'launchctl'; + args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`]; + } else if (platform === 'linux') { + cmd = 'systemctl'; + args = ['--user', 'restart', 'olp-proxy']; + } else { + io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`); + io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`); + return 2; + } + // Spawn + wait for exit; bubble up any error. + const result = await new Promise(resolve => { + const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' }); + child.on('error', e => resolve({ code: -1, error: e })); + child.on('exit', code => resolve({ code })); + }); + if (result.code === 0) { + if (io.wantJson) { + io.emitJson({ ok: true, cmd, args }); + } else { + io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor)); + } + return 0; + } + if (result.error?.code === 'ENOENT' || result.code === 127) { + io.errln(`Error: '${cmd}' not found on this system.`); + if (platform === 'darwin') { + io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`); + } else { + io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`); + } + return 2; + } + io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`); + return 2; +} + +// ── Subcommand: doctor ──────────────────────────────────────────────────── + +async function cmdDoctor(flags, io) { + const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] }); + const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] }); + const checkFilter = typeof flags.check === 'string' ? flags.check : undefined; + + const result = await runDoctor({ + olpHome, + proxyUrl, + checkFilter, + }); + + if (io.wantJson) { + io.emitJson(result); + return result.fail_count === 0 ? 0 : 2; + } + + io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor)); + io.log('─'.repeat(60)); + for (const c of result.checks) { + io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`); + } + io.log(''); + io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`); + if (result.next_action.ai_executable.length > 0) { + io.log(''); + io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor)); + for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`); + } + if (result.next_action.human_required.length > 0) { + io.log(''); + io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor)); + for (const step of result.next_action.human_required) io.log(` • ${step}`); + } + io.log(''); + io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor)); + return result.fail_count === 0 ? 0 : 2; +} + +// ── Subcommand: keys (delegate) ─────────────────────────────────────────── + +async function cmdKeys(rest, io) { + // Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings + // (no \n needed since the underlying CLI emits them itself). + return await runKeysCli(rest, { + out: s => process.stdout.write(s), + err: s => process.stderr.write(s), + }); +} + +// ── Usage ────────────────────────────────────────────────────────────────── + +const USAGE = `OLP operator CLI — Phase 4 (ADR 0010) + +Usage: + olp [args] [--json] [--proxy-url=] [--olp-home=] + +Subcommands: + status GET /v0/management/status (owner-only) + health GET /health + usage GET /v0/management/dashboard-data (owner-only) + models GET /v1/models + cache GET /cache/stats (owner-only) + providers list providers (registry + config providers.enabled) + chain show [] print routing.chains from ~/.olp/config.json + logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson + restart launchctl (macOS) / systemctl --user (Linux) + doctor [--check X] run diagnostic checks (id, category, or prefix filter) + keys [args ...] delegate to bin/olp-keys.mjs + help print this message + +Global flags: + --json emit raw JSON (silences human-readable formatting) + --proxy-url= override resolved proxy URL + --olp-home= override ~/.olp + +Env: + OLP_PROXY_URL full URL (overrides OLP_PORT) + OLP_PORT port for default URL (default: 4567) + OLP_API_KEY Bearer token for the proxy + OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4) + OLP_HOME ~/.olp override + +Exit codes: + 0 success + 1 bad usage / unknown subcommand + 2 network or HTTP error (4xx/5xx) + 3 auth missing / forbidden`; + +// ── runCli (testable entry) ─────────────────────────────────────────────── + +/** + * Run the CLI with explicit argv + IO streams. Returns the exit code (no + * process.exit). Exported for tests. + * + * @param {string[]} argv args after the script name + * @param {object} [opts] + * @param {(s: string) => void} [opts.out] + * @param {(s: string) => void} [opts.err] + * @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings + * @returns {Promise} + */ +export async function runCli(argv, opts = {}) { + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { + const io = makeIO({ ...opts, json: false }); + io.log(USAGE); + return argv.length === 0 ? 1 : 0; + } + + const [subcommand, ...rest] = argv; + + // `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs. + if (subcommand === 'keys') { + const io = makeIO({ ...opts, json: false }); + return await cmdKeys(rest, io); + } + + const { positional, flags } = parseArgv(rest); + const json = flags.json === true; + const io = makeIO({ ...opts, json }); + + switch (subcommand) { + case 'status': return await cmdStatus(flags, io); + case 'health': return await cmdHealth(flags, io); + case 'usage': return await cmdUsage(flags, io); + case 'models': return await cmdModels(flags, io); + case 'cache': return await cmdCache(flags, io); + case 'providers': return cmdProviders(flags, io); + case 'chain': { + // `olp chain show [model]` + const sub = positional[0]; + if (sub !== 'show') { + io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`); + return 1; + } + return cmdChainShow(positional.slice(1), flags, io); + } + case 'logs': return await cmdLogs(positional, flags, io); + case 'restart': return await cmdRestart(flags, io); + case 'doctor': return await cmdDoctor(flags, io); + default: + io.errln(`Error: unknown subcommand "${subcommand}".`); + io.errln(USAGE); + return 1; + } +} + +// ── Main guard ──────────────────────────────────────────────────────────── + +function _isMain() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { return false; } +} + +if (_isMain()) { + runCli(process.argv.slice(2)) + .then(code => process.exit(code)) + .catch(e => { + process.stderr.write(`Fatal: ${e?.stack ?? e}\n`); + process.exit(2); + }); +} diff --git a/docs/adr/0002-plugin-architecture.md b/docs/adr/0002-plugin-architecture.md index 94377e5..54cba08 100644 --- a/docs/adr/0002-plugin-architecture.md +++ b/docs/adr/0002-plugin-architecture.md @@ -7,7 +7,26 @@ ## Amendments -> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break). +> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break). + +### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX) + +- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too. +- **Change — add to Provider contract:** + - Introduce **OPTIONAL** `doctorChecks()` returning `DoctorCheck[]` where each `DoctorCheck` has the shape: + - `id: string` — unique per check, conventionally `.` (e.g. `anthropic.cli_available`, `anthropic.oauth_token_present`). + - `category: 'provider'` — fixed for plugin-contributed checks. The framework reserves `'server'`, `'auth'`, `'config'`, `'system'` for built-in checks. + - `async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }` — runs the probe. `status: 'fail'` makes `olp doctor` exit non-zero and contributes to the `kind: fix_provider` discriminator; `evidence.fix_commands[]` is concatenated into `next_action.ai_executable[]` and `evidence.human_steps[]` into `next_action.human_required[]`. +- **Backwards compatibility:** Plugins that omit `doctorChecks()` contribute zero provider checks. Their healthCheck() return value continues to flow through `/health.providers.status.` exactly as today. No existing plugin behaviour changes; no existing test breaks. `validateProvider` in `lib/providers/base.mjs` is updated to type-check `doctorChecks` only when present (must be a function); absence is allowed. +- **What `doctorChecks()` is for vs. what `healthCheck()` is for:** + - `healthCheck()` answers "is this provider currently usable?" — checked at the request-execution layer; output feeds `/health` and per-request retry decisions. + - `doctorChecks()` answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feeds `olp doctor` + the `next_action.ai_executable[]` repair templates that a downstream AI agent can paste-and-run. +- **Suggested probe set (per plugin):** + - `.cli_available` — spawn ` --version` with short timeout (≤3s); fail → fix_commands include install instruction. + - `._present` — check whether the auth file / env var the plugin's `readAuthArtifact()` reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be in `ai_executable[]`). +- **Authority:** ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed — `doctorChecks()` is an internal contract field. Implementation lands in D67 (this PR): `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, `lib/providers/mistral.mjs` each gain a `doctorChecks()` method covering `cli_available` + `_present`. +- **Tests:** Suite 32 (`bin/olp.mjs` CLI smoke) and Suite 33 (`olp doctor` framework) in `test-features.mjs` cover the contract amendment. Suite 33 specifically asserts: (a) a plugin without `doctorChecks()` contributes no provider checks (default behaviour), (b) a plugin with a failing `doctorChecks()` probe triggers `kind: fix_provider` and propagates its `evidence.fix_commands[]` into `next_action.ai_executable[]`, (c) all-passing checks yield `kind: noop`. +- **Procedural mechanism:** CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3. ### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1) diff --git a/lib/doctor.mjs b/lib/doctor.mjs new file mode 100644 index 0000000..5e91dce --- /dev/null +++ b/lib/doctor.mjs @@ -0,0 +1,576 @@ +/** + * lib/doctor.mjs — OLP doctor framework (Phase 4 / D65) + * + * Authority: ADR 0010 § Phase 4 D64-D67 + ADR 0002 Amendment 7 (D67) — + * per-provider `doctorChecks()` contract method that this framework consumes. + * + * `olp doctor` runs a set of `Check` objects. Each check has: + * - id: string (unique, e.g. 'server.running', 'anthropic.cli_available') + * - category: 'server'|'auth'|'config'|'provider'|'system' + * - async run(): { status: 'ok'|'fail'|'warn', message, evidence? } + * + * Built-in checks (categories server / auth / config / system) are defined in + * `buildBuiltinChecks()` below; per-provider checks are sourced from each loaded + * plugin's optional `doctorChecks()` method (ADR 0002 Amendment 7). + * + * Output shape (machine-readable — consumed by `bin/olp.mjs --json`): + * { + * schema_version: 1, + * generated_at: '2026-05-26T...', + * checks: [{ id, category, status, message, evidence? }], + * fail_count: number, + * warn_count: number, + * kind: 'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install', + * next_action: { ai_executable: string[], human_required: string[], verify: string }, + * summary: string, + * } + * + * `kind` precedence (highest first — the most upstream blocker wins): + * 1. fresh_install — config.exists FAIL (~/.olp/config.json missing/malformed) + * 2. fix_server — server.running FAIL + * 3. fix_oauth — auth.owner_key_exists FAIL + * 4. fix_provider — any provider-category FAIL + * 5. fix_config — any other config-category FAIL + * 6. noop — all OK (or WARN-only) + * + * `next_action.ai_executable[]` aggregates `evidence.fix_commands[]` from every + * FAIL check; `next_action.human_required[]` aggregates `evidence.human_steps[]`. + * `verify` is always `olp doctor` (re-run after applying the fix). + * + * Design notes: + * - Pure functions + dependency injection: callers pass `{ checks }` (which can + * be overridden for tests) plus a `{ now }` clock for deterministic timestamps. + * - No filesystem writes. No process.exit. No console.log. Callers handle I/O. + * - All checks run in parallel via Promise.all — individual check failures are + * captured (not propagated) so one broken check does not hide others. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { request as httpRequest } from 'node:http'; + +import { loadProviders } from './providers/index.mjs'; +import { loadFallbackConfigSync } from './fallback/engine.mjs'; +import { listKeys } from './keys.mjs'; + +// ── Schema version ──────────────────────────────────────────────────────── + +export const DOCTOR_SCHEMA_VERSION = 1; + +// ── Built-in check builders ─────────────────────────────────────────────── + +/** + * Resolve the OLP base URL the CLI / doctor will probe. + * Precedence: + * 1. opts.proxyUrl (explicit caller override) + * 2. OLP_PROXY_URL env (full URL like http://host:port) + * 3. http://127.0.0.1:${OLP_PORT || 4567} + */ +export function resolveProxyUrl(opts = {}) { + if (opts.proxyUrl) return String(opts.proxyUrl).replace(/\/+$/, ''); + if (process.env.OLP_PROXY_URL) return String(process.env.OLP_PROXY_URL).replace(/\/+$/, ''); + const port = process.env.OLP_PORT ?? '4567'; + return `http://127.0.0.1:${port}`; +} + +/** + * Resolve the OLP_HOME directory (mirrors lib/keys.mjs precedence). + * 1. opts.olpHome + * 2. OLP_HOME env + * 3. ~/.olp + */ +export function resolveOlpHome(opts = {}) { + if (opts.olpHome) return opts.olpHome; + if (process.env.OLP_HOME) return process.env.OLP_HOME; + return join(homedir(), '.olp'); +} + +/** + * Helper — issue a GET to the proxy with a tight timeout. Returns + * `{ ok: true, status, body }` or `{ ok: false, error }`. Never throws. + */ +async function httpGet(url, { timeoutMs = 3000, headers = {} } = {}) { + return new Promise(resolve => { + let done = false; + const finish = (v) => { if (!done) { done = true; resolve(v); } }; + let req; + try { + req = httpRequest(url, { method: 'GET', headers, timeout: timeoutMs }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers })); + }); + } catch (e) { + finish({ ok: false, error: String(e?.message ?? e) }); + return; + } + req.on('error', e => finish({ ok: false, error: String(e?.message ?? e) })); + req.on('timeout', () => { + try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ } + }); + req.end(); + }); +} + +/** + * Build the default check set. Test-mode overrides: + * - opts.injectChecks: [...Check] — REPLACES the built-in set entirely + * - opts.providersOverride: Map — REPLACES loaded providers for the per-provider sweep + * - opts.skipNetwork: true → omit server.running / server.version (offline mode) + * - opts.olpHome: override ~/.olp lookup + * - opts.proxyUrl: override resolveProxyUrl() + */ +/** + * D64-D67 reviewer P2-1: shell-quote a path before interpolation into + * `ai_executable[]` strings. Single-quote-wrap + escape any embedded single + * quote per POSIX shell rules: `foo'bar` → `'foo'\''bar'`. Defends against + * a malicious `OLP_HOME` env value injecting shell metacharacters into the + * suggested-fix command an AI agent (or human) might paste back. + * + * Risk surface is narrow at family scale (operator local env, single-user + * proxy), but the hardening cost is one helper. + * + * @param {string} s + * @returns {string} single-quoted shell-safe string + */ +function _shellQuote(s) { + return `'${String(s).replace(/'/g, "'\\''")}'`; +} + +export function buildBuiltinChecks(opts = {}) { + if (opts.injectChecks) return opts.injectChecks; + + const olpHome = resolveOlpHome(opts); + const configPath = join(olpHome, 'config.json'); + const proxyUrl = resolveProxyUrl(opts); + + const checks = []; + + // ── system.* ───────────────────────────────────────────────────────── + checks.push({ + id: 'system.node_version', + category: 'system', + async run() { + // package.json engines.node = >=18; check process.versions.node major >= 18 + const major = parseInt(String(process.versions.node).split('.')[0], 10); + if (Number.isFinite(major) && major >= 18) { + return { status: 'ok', message: `Node ${process.versions.node} (>=18)` }; + } + return { + status: 'fail', + message: `Node ${process.versions.node} is below the required >=18 (per package.json engines.node)`, + evidence: { + human_steps: [ + 'Install a current Node.js LTS (>=18) — see https://nodejs.org/en/download', + ], + }, + }; + }, + }); + + // ── config.* ───────────────────────────────────────────────────────── + checks.push({ + id: 'config.exists', + category: 'config', + async run() { + if (!existsSync(configPath)) { + return { + status: 'fail', + message: `${configPath} not found`, + evidence: { + // D64-D67 reviewer P2-1: shell-quote paths via _shellQuote so a + // malicious OLP_HOME env can't inject shell metacharacters into + // the suggested-fix command pasted into an AI agent. + fix_commands: [ + `mkdir -p ${_shellQuote(olpHome)}`, + `printf '%s\\n' '{"auth":{"allow_anonymous":false,"owner_only_endpoints":["/health"],"fallback_detail_header_policy":"owner_only"},"providers":{"enabled":{}},"routing":{"chains":{},"soft_triggers":{}},"streaming":{"heartbeat_interval_ms":0}}' > ${_shellQuote(configPath)}`, + ], + reference: 'docs/adr/0007-multi-key-auth.md § 3, docs/adr/0004-fallback-engine.md', + }, + }; + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + return { status: 'ok', message: `${configPath} parses` }; + } + return { status: 'fail', message: `${configPath} parses but is not a JSON object` }; + } catch (e) { + return { + status: 'fail', + message: `${configPath} unreadable / malformed: ${e?.message ?? e}`, + evidence: { + human_steps: [ + `Inspect ${configPath} — fix the JSON syntax error (or delete the file to fall back to the empty default and re-run olp doctor)`, + ], + }, + }; + } + }, + }); + + checks.push({ + id: 'config.providers_enabled', + category: 'config', + async run() { + try { + const cfg = loadFallbackConfigSync(configPath); + const enabled = cfg.providersEnabled ?? {}; + const enabledNames = Object.keys(enabled).filter(k => enabled[k] === true); + if (enabledNames.length > 0) { + return { status: 'ok', message: `${enabledNames.length} provider(s) enabled: ${enabledNames.join(', ')}` }; + } + return { + status: 'warn', + message: 'No providers enabled in config.json (all /v1/chat/completions requests will 503)', + evidence: { + human_steps: [ + `Edit ${configPath} → set providers.enabled. = true for at least one provider (anthropic / openai / mistral)`, + ], + reference: 'docs/adr/0002-plugin-architecture.md § Disable model', + }, + }; + } catch (e) { + return { status: 'fail', message: `Could not read providers.enabled from ${configPath}: ${e?.message ?? e}` }; + } + }, + }); + + checks.push({ + id: 'config.chains_configured', + category: 'config', + async run() { + try { + const cfg = loadFallbackConfigSync(configPath); + const chains = cfg.chains ?? {}; + const chainNames = Object.keys(chains); + if (chainNames.length > 0) { + return { status: 'ok', message: `${chainNames.length} chain(s) configured: ${chainNames.join(', ')}` }; + } + return { + status: 'warn', + message: 'No routing chains configured (single-hop mode; cross-provider fallback inactive)', + evidence: { + reference: 'docs/adr/0004-fallback-engine.md § Chain configuration', + }, + }; + } catch (e) { + return { status: 'fail', message: `Could not read routing.chains from ${configPath}: ${e?.message ?? e}` }; + } + }, + }); + + // ── auth.* ─────────────────────────────────────────────────────────── + checks.push({ + id: 'auth.owner_key_exists', + category: 'auth', + async run() { + // Per ADR 0007 § 9.4: process.env.OLP_OWNER_TOKEN also satisfies "owner identity". + if (process.env.OLP_OWNER_TOKEN) { + return { status: 'ok', message: 'OLP_OWNER_TOKEN env var present (synthetic env-owner per ADR 0007 § 9.4)' }; + } + try { + const keys = listKeys({ olpHome }); + const activeOwner = keys.find(k => k.owner_tier === 'owner' && k.revoked_at === null); + if (activeOwner) { + return { status: 'ok', message: `active owner key found: id=${activeOwner.id} name="${activeOwner.name}"` }; + } + return { + status: 'fail', + message: 'No active owner-tier key found in ~/.olp/keys/ and OLP_OWNER_TOKEN env unset', + evidence: { + fix_commands: [ + 'npx olp-keys keygen --owner', + ], + reference: 'docs/adr/0007-multi-key-auth.md § 9.1 (bootstrap & recovery)', + }, + }; + } catch (e) { + return { status: 'fail', message: `listKeys failed: ${e?.message ?? e}` }; + } + }, + }); + + // ── server.* ───────────────────────────────────────────────────────── + if (!opts.skipNetwork) { + checks.push({ + id: 'server.running', + category: 'server', + async run() { + const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 }); + if (!r.ok) { + return { + status: 'fail', + message: `${proxyUrl}/health unreachable: ${r.error}`, + evidence: { + fix_commands: [ + 'npx olp restart', + ], + reference: 'README.md § Running OLP', + }, + }; + } + if (r.status !== 200) { + return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` }; + } + return { status: 'ok', message: `${proxyUrl}/health → 200` }; + }, + }); + + checks.push({ + id: 'server.version', + category: 'server', + async run() { + // Read local package.json version + let localVersion = null; + try { + // Resolve relative to this file — lib/doctor.mjs → ../package.json + // import.meta.url gives a file:// URL; convert and join. + const here = new URL('../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(here, 'utf8')); + localVersion = pkg.version ?? null; + } catch { + return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' }; + } + const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 }); + if (!r.ok || r.status !== 200) { + return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` }; + } + let serverVersion = null; + try { + serverVersion = JSON.parse(r.body)?.version ?? null; + } catch { + return { status: 'warn', message: '/health returned non-JSON; cannot compare version' }; + } + if (!serverVersion) { + return { status: 'warn', message: '/health did not include version; cannot compare' }; + } + if (serverVersion === localVersion) { + return { status: 'ok', message: `local v${localVersion} matches running v${serverVersion}` }; + } + return { + status: 'warn', + message: `local v${localVersion} differs from running v${serverVersion} — restart to pick up the new code`, + evidence: { + fix_commands: [ + 'npx olp restart', + ], + }, + }; + }, + }); + } + + return checks; +} + +/** + * Sweep loaded providers for doctorChecks() (ADR 0002 Amendment 7). + * Plugins without doctorChecks() contribute nothing (default — back-compat). + * + * @param {object} opts + * @param {Map} [opts.providersOverride] — Map for tests + * @param {object} [opts.providersEnabled] — Record; default = all from config.json + * @returns {Check[]} + */ +export function collectProviderChecks(opts = {}) { + let providers; + if (opts.providersOverride) { + providers = opts.providersOverride; + } else { + const olpHome = resolveOlpHome(opts); + const configPath = join(olpHome, 'config.json'); + let enabled = opts.providersEnabled; + if (!enabled) { + try { + enabled = loadFallbackConfigSync(configPath).providersEnabled ?? {}; + } catch { + enabled = {}; + } + } + providers = loadProviders({ enabled }); + } + + const checks = []; + for (const [_name, plugin] of providers) { + if (typeof plugin?.doctorChecks !== 'function') continue; + let pluginChecks; + try { + pluginChecks = plugin.doctorChecks(); + } catch (e) { + // Misbehaving plugin — surface as a synthesized fail check, do not crash. + checks.push({ + id: `${plugin.name}.doctor_checks_threw`, + category: 'provider', + async run() { + return { status: 'fail', message: `doctorChecks() threw: ${e?.message ?? e}` }; + }, + }); + continue; + } + if (!Array.isArray(pluginChecks)) continue; + for (const c of pluginChecks) { + if (c && typeof c.id === 'string' && typeof c.run === 'function') { + checks.push({ + id: c.id, + category: c.category ?? 'provider', + run: c.run, + }); + } + } + } + return checks; +} + +// ── Discriminator (kind precedence) ─────────────────────────────────────── + +/** + * Given a flat results array, determine the next-action discriminator. + * Per ADR 0010 § D65 framework: + * fresh_install > fix_server > fix_oauth > fix_provider > fix_config > noop + */ +export function deriveKind(results) { + const failed = results.filter(r => r.status === 'fail'); + if (failed.length === 0) return 'noop'; + + if (failed.some(r => r.id === 'config.exists')) return 'fresh_install'; + if (failed.some(r => r.category === 'server')) return 'fix_server'; + if (failed.some(r => r.category === 'auth')) return 'fix_oauth'; + if (failed.some(r => r.category === 'provider')) return 'fix_provider'; + if (failed.some(r => r.category === 'config')) return 'fix_config'; + return 'fix_config'; +} + +/** + * Compose the next_action block from FAIL results' evidence. + */ +export function deriveNextAction(results, kind) { + const ai_executable = []; + const human_required = []; + for (const r of results) { + if (r.status !== 'fail') continue; + const ev = r.evidence ?? {}; + if (Array.isArray(ev.fix_commands)) ai_executable.push(...ev.fix_commands); + if (Array.isArray(ev.human_steps)) human_required.push(...ev.human_steps); + } + return { + ai_executable, + human_required, + verify: kind === 'noop' ? 'already healthy' : 'olp doctor', + }; +} + +// ── runDoctor (main entry) ──────────────────────────────────────────────── + +/** + * Execute every check in parallel; aggregate; derive kind + next_action. + * + * @param {object} [opts] + * @param {Check[]} [opts.injectChecks] — REPLACE the built-in + provider checks entirely + * @param {Check[]} [opts.extraChecks] — APPEND extra checks (after defaults) + * @param {string} [opts.checkFilter] — restrict to checks whose id OR category matches + * @param {Map} [opts.providersOverride] — for the per-provider sweep + * @param {object} [opts.providersEnabled] — Record + * @param {string} [opts.olpHome] — override ~/.olp + * @param {string} [opts.proxyUrl] — override the proxy URL + * @param {boolean} [opts.skipNetwork] — omit server.* checks + * @param {() => Date} [opts.now] — clock injection + * @returns {Promise} + */ +export async function runDoctor(opts = {}) { + const now = opts.now ?? (() => new Date()); + + let checks; + if (opts.injectChecks) { + checks = [...opts.injectChecks]; + } else { + checks = [ + ...buildBuiltinChecks(opts), + ...collectProviderChecks(opts), + ]; + } + if (opts.extraChecks) checks.push(...opts.extraChecks); + + // --check : restrict to checks whose id OR category startsWith / equals the filter. + // Match rule: exact id match, exact category match, OR id startsWith `.` + // (so --check anthropic matches both anthropic.cli_available and anthropic.oauth_token_present). + if (opts.checkFilter) { + const f = String(opts.checkFilter); + checks = checks.filter(c => + c.id === f + || c.category === f + || c.id.startsWith(`${f}.`) + ); + } + + // Run all checks in parallel. Capture per-check failures (do not let one throw + // hide the rest of the diagnostic). + const results = await Promise.all(checks.map(async c => { + try { + const r = await c.run(); + return { + id: c.id, + category: c.category, + status: r?.status ?? 'fail', + message: r?.message ?? '(check returned no message)', + ...(r?.evidence !== undefined ? { evidence: r.evidence } : {}), + }; + } catch (e) { + return { + id: c.id, + category: c.category, + status: 'fail', + message: `check threw: ${e?.message ?? e}`, + }; + } + })); + + const fail_count = results.filter(r => r.status === 'fail').length; + const warn_count = results.filter(r => r.status === 'warn').length; + const ok_count = results.filter(r => r.status === 'ok').length; + const kind = deriveKind(results); + const next_action = deriveNextAction(results, kind); + + let summary; + if (fail_count === 0 && warn_count === 0) { + summary = `all ${ok_count} checks ok`; + } else if (fail_count === 0) { + summary = `${ok_count} ok, ${warn_count} warn — no FAIL; kind=${kind}`; + } else { + const firstFail = results.find(r => r.status === 'fail'); + summary = `${fail_count} of ${results.length} checks failed — ${firstFail?.id ?? '?'} (${firstFail?.message?.slice(0, 80) ?? ''})`; + } + + return { + schema_version: DOCTOR_SCHEMA_VERSION, + generated_at: now().toISOString(), + checks: results, + fail_count, + warn_count, + ok_count, + kind, + next_action, + summary, + }; +} + +/** + * @typedef {Object} Check + * @property {string} id + * @property {'server'|'auth'|'config'|'provider'|'system'} category + * @property {() => Promise<{ status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }>} run + */ + +/** + * @typedef {Object} DoctorResult + * @property {number} schema_version + * @property {string} generated_at (ISO timestamp) + * @property {Array<{ id: string, category: string, status: 'ok'|'fail'|'warn', message: string, evidence?: object }>} checks + * @property {number} fail_count + * @property {number} warn_count + * @property {number} ok_count + * @property {'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install'} kind + * @property {{ ai_executable: string[], human_required: string[], verify: string }} next_action + * @property {string} summary + */ diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index 6cff6c0..9ae7e9c 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -485,6 +485,64 @@ function _defaultBinaryExists() { } } +// ── doctorChecks (ADR 0002 Amendment 7, D67) ────────────────────────────── +// Per-plugin probe templates consumed by `olp doctor`. Each check returns +// { status, message, evidence? } where evidence.fix_commands[] flow into the +// next_action.ai_executable[] block and evidence.human_steps[] into +// next_action.human_required[]. +// +// Probes: +// anthropic.cli_available — `claude --version` resolves on PATH (or via OLP_CLAUDE_BIN) +// anthropic.oauth_token_present — `readAuthArtifact()` returns a non-empty accessToken +// +// Both probes share the existing test seams (binaryExists / readAuthArtifact) so the +// suite can stub them deterministically without spawning the real binary. +export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) { + const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; + const authRead = _authReadFn ?? readAuthArtifact; + return [ + { + id: 'anthropic.cli_available', + category: 'provider', + async run() { + if (binaryExists()) { + return { status: 'ok', message: '`claude` binary resolved on PATH' }; + } + return { + status: 'fail', + message: '`claude` binary not found on PATH (and OLP_CLAUDE_BIN unset/invalid)', + evidence: { + fix_commands: [ + 'npm install -g @anthropic-ai/claude-code', + ], + reference: 'https://docs.anthropic.com/en/docs/claude-code/setup', + }, + }; + }, + }, + { + id: 'anthropic.oauth_token_present', + category: 'provider', + async run() { + const auth = authRead(); + if (auth?.accessToken) { + return { status: 'ok', message: 'OAuth credential present (.credentials.json / env / keychain)' }; + } + return { + status: 'fail', + message: 'Anthropic OAuth credential missing — none of ANTHROPIC_OAUTH_TOKEN env, ~/.claude/.credentials.json, or login keychain returned a token', + evidence: { + human_steps: [ + 'run: claude (the first interactive launch prompts for browser OAuth login)', + ], + reference: 'https://docs.anthropic.com/en/docs/claude-code/setup#authentication', + }, + }; + }, + }, + ]; +} + // ── Provider export ─────────────────────────────────────────────────────── // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" including D4 // contractVersion fold-in per reviewer F3. @@ -509,6 +567,8 @@ const anthropic = { estimateCost, quotaStatus, healthCheck, + // ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`. + doctorChecks: () => doctorChecks(), hints: { requiresTTY: false, concurrentSpawnSafe: true, diff --git a/lib/providers/base.mjs b/lib/providers/base.mjs index 0fb0140..37dcd79 100644 --- a/lib/providers/base.mjs +++ b/lib/providers/base.mjs @@ -30,6 +30,13 @@ * collectAllChunks directly. ADR 0002 Amendment 3 (D23). */ +/** + * @typedef {Object} DoctorCheck + * @property {string} id - unique per check, e.g. 'anthropic.cli_available' + * @property {'provider'} category - fixed for plugin-contributed checks (ADR 0002 Amendment 7) + * @property {function} run - async () => { status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } } + */ + /** * @typedef {Object} ProviderContractV1 * @property {string} name - unique lowercase key @@ -41,6 +48,7 @@ * @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null * @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null * @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string} + * @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67) * @property {ProviderHints} hints */ @@ -113,6 +121,13 @@ export function validateProvider(p) { errors.push('healthCheck must be a function'); } + // ADR 0002 Amendment 7 (D67): doctorChecks() is optional. When present it must be + // a function; absence is allowed (plugin contributes no provider-tier checks to + // `olp doctor` — built-in server/system/auth checks still run). + if (p.doctorChecks !== undefined && typeof p.doctorChecks !== 'function') { + errors.push('doctorChecks must be a function or omitted'); + } + if (!p.hints || typeof p.hints !== 'object') { errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }'); } else { diff --git a/lib/providers/codex.mjs b/lib/providers/codex.mjs index 07c8de5..1c2cb6c 100644 --- a/lib/providers/codex.mjs +++ b/lib/providers/codex.mjs @@ -637,6 +637,59 @@ function _defaultBinaryExists() { } } +// ── doctorChecks (ADR 0002 Amendment 7, D67) ────────────────────────────── +// See lib/providers/anthropic.mjs doctorChecks header for the contract. +// +// Probes: +// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN) +// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken +// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json) +export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) { + const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; + const authRead = _authReadFn ?? readAuthArtifact; + return [ + { + id: 'openai.cli_available', + category: 'provider', + async run() { + if (binaryExists()) { + return { status: 'ok', message: '`codex` binary resolved on PATH' }; + } + return { + status: 'fail', + message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)', + evidence: { + fix_commands: [ + 'npm install -g @openai/codex', + ], + reference: 'https://developers.openai.com/codex/cli/reference', + }, + }; + }, + }, + { + id: 'openai.auth_present', + category: 'provider', + async run() { + const auth = authRead(); + if (auth?.accessToken) { + return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' }; + } + return { + status: 'fail', + message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)', + evidence: { + human_steps: [ + 'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)', + ], + reference: 'https://developers.openai.com/codex/cli/reference', + }, + }; + }, + }, + ]; +} + // ── Provider export ─────────────────────────────────────────────────────── // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion. @@ -662,6 +715,8 @@ const codex = { estimateCost, quotaStatus, healthCheck, + // ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`. + doctorChecks: () => doctorChecks(), hints: { requiresTTY: false, // codex exec runs headless (per CLI reference § exec) concurrentSpawnSafe: true, // each invocation is independent diff --git a/lib/providers/mistral.mjs b/lib/providers/mistral.mjs index 9a3f1f1..8a4c7b1 100644 --- a/lib/providers/mistral.mjs +++ b/lib/providers/mistral.mjs @@ -764,6 +764,60 @@ function _defaultBinaryExists() { } } +// ── doctorChecks (ADR 0002 Amendment 7, D67) ────────────────────────────── +// See lib/providers/anthropic.mjs doctorChecks header for the contract. +// +// Probes: +// mistral.cli_available — `vibe --version` resolves on PATH (or via OLP_VIBE_BIN) +// mistral.api_key_present — readAuthArtifact() returns apiKey (MISTRAL_API_KEY env or ~/.vibe/.env) +// (DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration — +// auth from MISTRAL_API_KEY env / ~/.vibe/.env) +export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) { + const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; + const authRead = _authReadFn ?? readAuthArtifact; + return [ + { + id: 'mistral.cli_available', + category: 'provider', + async run() { + if (binaryExists()) { + return { status: 'ok', message: '`vibe` binary resolved on PATH' }; + } + return { + status: 'fail', + message: '`vibe` binary not found on PATH (and OLP_VIBE_BIN unset/invalid)', + evidence: { + fix_commands: [ + 'npm install -g @mistralai/vibe', + ], + reference: 'https://docs.mistral.ai/mistral-vibe/terminal/quickstart', + }, + }; + }, + }, + { + id: 'mistral.api_key_present', + category: 'provider', + async run() { + const auth = authRead(); + if (auth?.apiKey) { + return { status: 'ok', message: 'Mistral API key present (env MISTRAL_API_KEY or ~/.vibe/.env)' }; + } + return { + status: 'fail', + message: 'Mistral API key missing — neither MISTRAL_API_KEY env nor ~/.vibe/.env (or $VIBE_HOME/.env) supplied a key', + evidence: { + human_steps: [ + 'export MISTRAL_API_KEY= # or write MISTRAL_API_KEY=... into ~/.vibe/.env', + ], + reference: 'https://docs.mistral.ai/mistral-vibe/terminal/configuration', + }, + }; + }, + }, + ]; +} + // ── Provider export ─────────────────────────────────────────────────────── // Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion. @@ -795,6 +849,8 @@ const mistral = { estimateCost, quotaStatus, healthCheck, + // ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`. + doctorChecks: () => doctorChecks(), hints: { requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode concurrentSpawnSafe: true, // each invocation is independent diff --git a/package.json b/package.json index 86f9c2e..99373b4 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "type": "module", "main": "server.mjs", "bin": { + "olp": "./bin/olp.mjs", "olp-keys": "./bin/olp-keys.mjs", "olp-audit-rotate": "./bin/olp-audit-rotate.mjs" }, "scripts": { "start": "node server.mjs", "test": "node test-features.mjs", + "olp": "node bin/olp.mjs", "olp-keys": "node bin/olp-keys.mjs", "olp-audit-rotate": "node bin/olp-audit-rotate.mjs" }, diff --git a/test-features.mjs b/test-features.mjs index 3558bb2..7c141a1 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -13591,3 +13591,467 @@ describe('Suite 31 — D63 /v0/management/status (ADR 0010 § Phase 4 D61-D63)', `total_requests expected >= 1, got ${body.stats.total_requests}`); }); }); + +// ── Suite 32: D64 bin/olp.mjs CLI scaffold (ADR 0010 § Phase 4 D64-D67) ─── +// +// Smoke tests for the operator CLI. The CLI is invoked in-process via its +// exported runCli() so we get exit codes without spawning a subprocess. The +// process.env is mutated per-test (saved + restored in before/after) so the +// runtime resolution paths (OLP_PROXY_URL / OLP_PORT / OLP_API_KEY / OLP_HOME) +// behave deterministically. +// +// The CLI sources are: +// bin/olp.mjs — runCli(argv, { out, err, useColor }) → exit code +// lib/doctor.mjs — runDoctor() consumed by `olp doctor` +// +// These tests do NOT spawn a real OLP server for the HTTP subcommands; instead +// each HTTP test starts an ephemeral createOlpServer() listening on port 0 and +// points the CLI at that port via --proxy-url. The owner_token used is created +// per-test via createKey({ owner_tier: 'owner' }). + +import { runCli as runOlpCli, parseArgv as parseOlpArgv, resolveBearerToken as olpResolveBearerToken } from './bin/olp.mjs'; + +describe('Suite 32 — D64 bin/olp.mjs CLI scaffold (ADR 0010 § Phase 4 D64-D67)', () => { + const SAVED_ENV_32 = { + OLP_HOME: process.env.OLP_HOME, + OLP_PROXY_URL: process.env.OLP_PROXY_URL, + OLP_PORT: process.env.OLP_PORT, + OLP_API_KEY: process.env.OLP_API_KEY, + OLP_OWNER_TOKEN: process.env.OLP_OWNER_TOKEN, + }; + let TMP32; + + before(() => { + TMP32 = mkdtempSync(pathJoin(tmpdir(), 'olp-test-32-')); + process.env.OLP_HOME = TMP32; + // Clear conflicting env vars for deterministic resolution + delete process.env.OLP_PROXY_URL; + delete process.env.OLP_PORT; + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + }); + + after(() => { + for (const [k, v] of Object.entries(SAVED_ENV_32)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(TMP32, { recursive: true, force: true }); + }); + + // Helper: capture stdout / stderr buffers via IO writers + function makeCapture() { + let out = '', err = ''; + return { + out: s => { out += s; }, + err: s => { err += s; }, + get stdout() { return out; }, + get stderr() { return err; }, + }; + } + + it('32a — parseArgv: --flag=value + --flag value + bare --flag', () => { + const a = parseOlpArgv(['--json', '--port=4567', '--name', 'foo', 'positional']); + assert.equal(a.flags.json, true); + assert.equal(a.flags.port, '4567'); + assert.equal(a.flags.name, 'foo'); + assert.deepEqual(a.positional, ['positional']); + }); + + it('32b — `olp` (no args) prints USAGE and returns exit code 1', async () => { + const cap = makeCapture(); + const code = await runOlpCli([], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 1, 'no-args should return 1'); + assert.match(cap.stdout, /OLP operator CLI/); + assert.match(cap.stdout, /Subcommands:/); + }); + + it('32c — `olp help` prints USAGE and returns exit code 0', async () => { + const cap = makeCapture(); + const code = await runOlpCli(['help'], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 0); + assert.match(cap.stdout, /OLP operator CLI/); + }); + + it('32d — `olp unknown` prints error + USAGE on stderr, returns exit code 1', async () => { + const cap = makeCapture(); + const code = await runOlpCli(['frobnicate'], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 1); + assert.match(cap.stderr, /unknown subcommand "frobnicate"/); + assert.match(cap.stderr, /OLP operator CLI/); + }); + + it('32e — `olp providers` (local, no HTTP) → lists registry entries', async () => { + const cap = makeCapture(); + const code = await runOlpCli(['providers'], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 0); + // All three shipped plugin keys must appear + assert.match(cap.stdout, /anthropic/); + assert.match(cap.stdout, /openai/); + assert.match(cap.stdout, /mistral/); + // No providers enabled in the empty TMP32 config → all show disabled + assert.match(cap.stdout, /disabled/); + }); + + it('32f — `olp providers --json` emits parseable JSON with providers[]', async () => { + const cap = makeCapture(); + const code = await runOlpCli(['providers', '--json'], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 0); + const body = JSON.parse(cap.stdout); + assert.ok(Array.isArray(body.providers)); + assert.ok(body.providers.length >= 3); + const names = body.providers.map(p => p.name); + assert.ok(names.includes('anthropic')); + assert.ok(names.includes('openai')); + assert.ok(names.includes('mistral')); + }); + + it('32g — `olp chain show` with no chains in config prints "no chains configured"', async () => { + const cap = makeCapture(); + const code = await runOlpCli(['chain', 'show'], { out: cap.out, err: cap.err, useColor: false }); + assert.equal(code, 0); + assert.match(cap.stdout, /no chains configured/); + }); + + it('32h — `olp status` against an ephemeral OLP server with owner token → 200', async () => { + // Spin up an ephemeral server bound to a random port; CLI talks to it. + __setProvidersEnabled({}); + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + const mod = await import('./server.mjs'); + mod.__clearCache(); + mod.__clearRecentErrors(); + mod.__resetRequestCounters(); + + const server32 = mod.createOlpServer(); + await new Promise((resolve, reject) => { + server32.listen(0, '127.0.0.1', resolve); + server32.once('error', reject); + }); + const port32 = server32.address().port; + try { + // Create owner key + use it via OLP_API_KEY env (resolveBearerToken reads it first) + const { plaintext_token } = createKey({ + name: '32h-owner', owner_tier: 'owner', providers_enabled: '*', olpHome: TMP32, + }); + const SAVED_TOK = process.env.OLP_API_KEY; + process.env.OLP_API_KEY = plaintext_token; + try { + const cap = makeCapture(); + const code = await runOlpCli( + ['status', `--proxy-url=http://127.0.0.1:${port32}`, '--json'], + { out: cap.out, err: cap.err, useColor: false }, + ); + assert.equal(code, 0, `status JSON exit non-zero; stderr=${cap.stderr}`); + const body = JSON.parse(cap.stdout); + assert.equal(body.ok, true); + assert.ok(typeof body.version === 'string'); + assert.ok(typeof body.uptime_ms === 'number'); + } finally { + if (SAVED_TOK === undefined) delete process.env.OLP_API_KEY; + else process.env.OLP_API_KEY = SAVED_TOK; + } + } finally { + await new Promise(r => server32.close(r)); + __resetProvidersEnabled(); + __setAuthConfig({ allow_anonymous: true, owner_only_endpoints: [], fallback_detail_header_policy: 'all' }); + } + }); + + it('32i — `olp status` against a non-existent port → exit 2 ECONNREFUSED', async () => { + // Pick a random high port unlikely to be in use; the CLI must detect + // ECONNREFUSED and surface the helpful hint. + const cap = makeCapture(); + const code = await runOlpCli( + ['status', '--proxy-url=http://127.0.0.1:1'], // port 1 → ECONNREFUSED on macOS/Linux + { out: cap.out, err: cap.err, useColor: false }, + ); + assert.equal(code, 2, `expected ECONNREFUSED → exit 2, got ${code}; stderr=${cap.stderr}`); + assert.match(cap.stderr, /unreachable|ECONNREFUSED/); + }); + + it('32j — resolveBearerToken precedence: OLP_API_KEY > OLP_OWNER_TOKEN > null', () => { + const SAVED_API = process.env.OLP_API_KEY; + const SAVED_OWN = process.env.OLP_OWNER_TOKEN; + try { + // 1. Neither set → null + delete process.env.OLP_API_KEY; + delete process.env.OLP_OWNER_TOKEN; + assert.equal(olpResolveBearerToken(), null); + + // 2. Only OLP_OWNER_TOKEN → that wins + process.env.OLP_OWNER_TOKEN = 'owner-tok-32j'; + assert.equal(olpResolveBearerToken(), 'owner-tok-32j'); + + // 3. OLP_API_KEY also set → API key wins + process.env.OLP_API_KEY = 'api-key-32j'; + assert.equal(olpResolveBearerToken(), 'api-key-32j'); + } finally { + if (SAVED_API === undefined) delete process.env.OLP_API_KEY; + else process.env.OLP_API_KEY = SAVED_API; + if (SAVED_OWN === undefined) delete process.env.OLP_OWNER_TOKEN; + else process.env.OLP_OWNER_TOKEN = SAVED_OWN; + } + }); +}); + +// ── Suite 33: D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67) ─── +// +// Framework tests for `olp doctor`. Uses runDoctor()'s opts.injectChecks +// hook to drive every kind branch (noop / fresh_install / fix_server / +// fix_oauth / fix_provider / fix_config) deterministically without depending +// on real filesystem or network state. + +import { + runDoctor as runOlpDoctor, + buildBuiltinChecks as buildOlpBuiltinChecks, + collectProviderChecks as collectOlpProviderChecks, + deriveKind as deriveOlpKind, + deriveNextAction as deriveOlpNextAction, + resolveProxyUrl as resolveOlpProxyUrl, + DOCTOR_SCHEMA_VERSION, +} from './lib/doctor.mjs'; + +describe('Suite 33 — D65 lib/doctor.mjs framework (ADR 0010 § Phase 4 D64-D67)', () => { + + // Helper: build a fake check with deterministic status + optional evidence + function fakeCheck(id, category, status, message = 'fake', evidence) { + return { + id, + category, + async run() { + return evidence ? { status, message, evidence } : { status, message }; + }, + }; + } + + it('33a — all-ok checks → kind=noop, next_action.ai_executable=[]', async () => { + const result = await runOlpDoctor({ + injectChecks: [ + fakeCheck('system.node_version', 'system', 'ok', 'Node 20'), + fakeCheck('server.running', 'server', 'ok', 'live'), + fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'), + ], + }); + assert.equal(result.schema_version, DOCTOR_SCHEMA_VERSION); + assert.equal(result.fail_count, 0); + assert.equal(result.kind, 'noop'); + assert.deepEqual(result.next_action.ai_executable, []); + assert.deepEqual(result.next_action.human_required, []); + assert.equal(result.next_action.verify, 'already healthy'); + assert.match(result.summary, /all 3 checks ok/); + }); + + it('33b — server.running FAIL → kind=fix_server + ai_executable propagates', async () => { + const result = await runOlpDoctor({ + injectChecks: [ + fakeCheck('server.running', 'server', 'fail', 'unreachable', { + fix_commands: ['olp restart'], + }), + fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'), + ], + }); + assert.equal(result.fail_count, 1); + assert.equal(result.kind, 'fix_server'); + assert.deepEqual(result.next_action.ai_executable, ['olp restart']); + assert.equal(result.next_action.verify, 'olp doctor'); + }); + + it('33c — auth.owner_key_exists FAIL (no server fail) → kind=fix_oauth', async () => { + const result = await runOlpDoctor({ + injectChecks: [ + fakeCheck('server.running', 'server', 'ok', 'live'), + fakeCheck('auth.owner_key_exists', 'auth', 'fail', 'no owner', { + fix_commands: ['npx olp-keys keygen --owner'], + }), + ], + }); + assert.equal(result.kind, 'fix_oauth'); + assert.deepEqual(result.next_action.ai_executable, ['npx olp-keys keygen --owner']); + }); + + it('33d — config.exists FAIL beats everything else → kind=fresh_install', async () => { + const result = await runOlpDoctor({ + injectChecks: [ + fakeCheck('config.exists', 'config', 'fail', 'missing', { + fix_commands: ['mkdir -p ~/.olp'], + }), + fakeCheck('server.running', 'server', 'fail', 'unreachable', { + fix_commands: ['olp restart'], + }), + fakeCheck('auth.owner_key_exists', 'auth', 'fail', 'no owner'), + ], + }); + assert.equal(result.kind, 'fresh_install'); + // ai_executable concatenates all FAIL evidence.fix_commands + assert.ok(result.next_action.ai_executable.includes('mkdir -p ~/.olp')); + assert.ok(result.next_action.ai_executable.includes('olp restart')); + }); + + it('33e — provider-category FAIL only → kind=fix_provider, human_steps propagate', async () => { + const result = await runOlpDoctor({ + injectChecks: [ + fakeCheck('server.running', 'server', 'ok', 'live'), + fakeCheck('auth.owner_key_exists', 'auth', 'ok', 'present'), + fakeCheck('anthropic.oauth_token_present', 'provider', 'fail', 'missing token', { + human_steps: ['run: claude (browser OAuth)'], + }), + ], + }); + assert.equal(result.kind, 'fix_provider'); + assert.deepEqual(result.next_action.ai_executable, []); + assert.deepEqual(result.next_action.human_required, ['run: claude (browser OAuth)']); + }); + + it('33f — collectProviderChecks reads plugin doctorChecks() (ADR 0002 Amendment 7)', () => { + // Synthetic plugin map that includes one plugin WITH doctorChecks() + one + // plugin WITHOUT (default behaviour — back-compat — contributes nothing). + const providers = new Map(); + providers.set('alpha', { + name: 'alpha', + doctorChecks() { + return [ + { id: 'alpha.cli_available', category: 'provider', async run() { return { status: 'ok', message: 'alpha bin ok' }; } }, + { id: 'alpha.auth_present', category: 'provider', async run() { return { status: 'fail', message: 'alpha missing' }; } }, + ]; + }, + }); + providers.set('legacy', { + name: 'legacy', + // No doctorChecks() — plugin pre-amendment 7; collectProviderChecks ignores it + }); + const checks = collectOlpProviderChecks({ providersOverride: providers }); + const ids = checks.map(c => c.id).sort(); + assert.deepEqual(ids, ['alpha.auth_present', 'alpha.cli_available']); + // Categories normalized to 'provider' + assert.ok(checks.every(c => c.category === 'provider')); + }); + + it('33g — plugin doctorChecks() that throws is captured (does not crash framework)', async () => { + const providers = new Map(); + providers.set('bad', { + name: 'bad', + doctorChecks() { throw new Error('boom from bad plugin'); }, + }); + const checks = collectOlpProviderChecks({ providersOverride: providers }); + assert.equal(checks.length, 1); + assert.equal(checks[0].id, 'bad.doctor_checks_threw'); + // Run it — should resolve to fail without re-throwing + const r = await checks[0].run(); + assert.equal(r.status, 'fail'); + assert.match(r.message, /boom from bad plugin/); + }); + + it('33h — --check filter restricts to matching id/category/prefix', async () => { + // Filter by category + const r1 = await runOlpDoctor({ + injectChecks: [ + fakeCheck('a.x', 'provider', 'ok'), + fakeCheck('b.y', 'system', 'ok'), + ], + checkFilter: 'system', + }); + assert.equal(r1.checks.length, 1); + assert.equal(r1.checks[0].id, 'b.y'); + + // Filter by id prefix + const r2 = await runOlpDoctor({ + injectChecks: [ + fakeCheck('anthropic.cli_available', 'provider', 'ok'), + fakeCheck('anthropic.oauth_token_present', 'provider', 'ok'), + fakeCheck('openai.cli_available', 'provider', 'ok'), + ], + checkFilter: 'anthropic', + }); + assert.equal(r2.checks.length, 2); + assert.ok(r2.checks.every(c => c.id.startsWith('anthropic.'))); + + // Filter by exact id + const r3 = await runOlpDoctor({ + injectChecks: [ + fakeCheck('anthropic.cli_available', 'provider', 'ok'), + fakeCheck('anthropic.oauth_token_present', 'provider', 'ok'), + ], + checkFilter: 'anthropic.cli_available', + }); + assert.equal(r3.checks.length, 1); + assert.equal(r3.checks[0].id, 'anthropic.cli_available'); + }); + + it('33i — built-in checks run against a temp OLP_HOME (no config → fresh_install)', async () => { + // skipNetwork: true so server.* checks don't run against the localhost port. + const tmpEmpty = mkdtempSync(pathJoin(tmpdir(), 'olp-doc-33i-')); + try { + const result = await runOlpDoctor({ + olpHome: tmpEmpty, + skipNetwork: true, + // Don't load real provider plugins for this run; pass an empty Map. + providersOverride: new Map(), + }); + assert.equal(result.schema_version, DOCTOR_SCHEMA_VERSION); + // config.exists must FAIL (no ~/.olp/config.json in tmpEmpty) + const configCheck = result.checks.find(c => c.id === 'config.exists'); + assert.ok(configCheck, 'config.exists check should run'); + assert.equal(configCheck.status, 'fail'); + // kind must be fresh_install per the precedence rule + assert.equal(result.kind, 'fresh_install'); + // ai_executable must include the mkdir + printf seed bootstrap + assert.ok(result.next_action.ai_executable.some(c => c.startsWith('mkdir -p'))); + } finally { + rmSync(tmpEmpty, { recursive: true, force: true }); + } + }); + + it('33j — anthropic plugin doctorChecks() returns the documented probe set', () => { + // Plugin contract: ADR 0002 Amendment 7. anthropic.mjs ships + // anthropic.cli_available + anthropic.oauth_token_present. + const checks = anthropic.doctorChecks(); + assert.ok(Array.isArray(checks)); + const ids = checks.map(c => c.id).sort(); + assert.deepEqual(ids, ['anthropic.cli_available', 'anthropic.oauth_token_present']); + assert.ok(checks.every(c => c.category === 'provider')); + assert.ok(checks.every(c => typeof c.run === 'function')); + }); + + it('33k — resolveProxyUrl: explicit > env OLP_PROXY_URL > OLP_PORT > default', () => { + const SAVED_URL = process.env.OLP_PROXY_URL; + const SAVED_PORT = process.env.OLP_PORT; + try { + delete process.env.OLP_PROXY_URL; + delete process.env.OLP_PORT; + // 1. default = http://127.0.0.1:4567 + assert.equal(resolveOlpProxyUrl(), 'http://127.0.0.1:4567'); + // 2. OLP_PORT env + process.env.OLP_PORT = '9999'; + assert.equal(resolveOlpProxyUrl(), 'http://127.0.0.1:9999'); + // 3. OLP_PROXY_URL wins over OLP_PORT + process.env.OLP_PROXY_URL = 'https://example.com:8443'; + assert.equal(resolveOlpProxyUrl(), 'https://example.com:8443'); + // 4. Explicit opts wins over everything + assert.equal(resolveOlpProxyUrl({ proxyUrl: 'http://override:1234' }), 'http://override:1234'); + } finally { + if (SAVED_URL === undefined) delete process.env.OLP_PROXY_URL; + else process.env.OLP_PROXY_URL = SAVED_URL; + if (SAVED_PORT === undefined) delete process.env.OLP_PORT; + else process.env.OLP_PORT = SAVED_PORT; + } + }); + + it('33l — deriveKind / deriveNextAction pure-function shape', () => { + // deriveKind unit + assert.equal(deriveOlpKind([{ status: 'ok', category: 'server', id: 'a' }]), 'noop'); + assert.equal(deriveOlpKind([ + { status: 'fail', category: 'server', id: 's.r' }, + { status: 'fail', category: 'auth', id: 'a.o' }, + ]), 'fix_server'); // server beats auth + + // deriveNextAction aggregates evidence + const na = deriveOlpNextAction([ + { status: 'fail', id: 'x', category: 'provider', evidence: { fix_commands: ['cmd1'], human_steps: ['step1'] } }, + { status: 'fail', id: 'y', category: 'provider', evidence: { fix_commands: ['cmd2'] } }, + { status: 'ok', id: 'z', category: 'provider' }, + ], 'fix_provider'); + assert.deepEqual(na.ai_executable, ['cmd1', 'cmd2']); + assert.deepEqual(na.human_required, ['step1']); + assert.equal(na.verify, 'olp doctor'); + }); +});