mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule 11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via the new contract method; ADR amendment authorizes the contract change. Single PR is the minimum reviewable unit for "does plugin amendment + plugin impl + doctor consumer line up?" ## D64 — bin/olp.mjs Node CLI scaffold Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with python3 JSON parsing is a known fragile point; OLP standardizes on Node). Subcommands: - status / health / usage / models / cache — HTTP calls to existing endpoints - providers — local: cross-references models-registry.json + config.json - chain show [<model>] — local: prints routing.chains from ~/.olp/config.json - logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query - restart — launchctl (macOS) / systemctl --user (Linux), best-effort - keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated) - doctor [--check <id|category>] [--json] — D65 framework - help / --help / -h Token / URL resolution: - OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default) - OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces helpful 401 message pointing at olp-keys keygen) Output: - Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed under --json) - --json: raw JSON for scripting - Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth No npm deps. Built-ins only. Installed via package.json bin entry so `npx olp <subcommand>` works. ## D65 — lib/doctor.mjs framework Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the OCP audit's #2 inheritance candidate). Machine-readable next_action so a Claude Code / Cursor / etc. agent can self-repair OLP. Check shape: { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } } Built-in checks: server.running, server.version, config.exists, config.providers_enabled, config.chains_configured, auth.owner_key_exists, system.node_version. Per-provider checks collected dynamically via provider.doctorChecks() per D67. --json output: { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install| fix_server|fix_provider, next_action: { ai_executable: [], human_required: [], verify: 'olp doctor' }, summary } --check <id-or-category> for tight repair-loop fast paths. ## D66 — Per-provider doctorChecks() implementations Each shipped plugin contributes its own checks (lives in plugin file so the provider's maintainer updates it naturally): - anthropic.mjs: cli_available (claude --version) + oauth_token_present (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env) - codex.mjs: cli_available (codex --version) + auth_present (~/.codex/config.json) - mistral.mjs: cli_available (vibe --version) + api_key_present (MISTRAL_API_KEY env OR ~/.vibe/.env) Each fail returns evidence.fix_commands (for ai_executable[]) or evidence.human_required (e.g., 'run: claude auth login'). ## D67 — ADR 0002 Amendment 7 New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the Provider contract. Backwards compatible — plugins without doctorChecks() contribute no provider checks (default behavior). Validator extended in lib/providers/base.mjs validateProvider. ## Test count 636 → 658 (+22 tests across Suites 32, 33). - Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE, unknown-subcommand, providers local + --json, chain show, status via ephemeral server with owner token, ECONNREFUSED → exit 2, resolveBearerToken precedence - Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches (noop / fresh_install / fix_server / fix_oauth / fix_provider), collectProviderChecks reads doctorChecks(), throwing plugin captured, --check filter, built-in checks against temp HOME, anthropic plugin probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units ## Scope discipline server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints. No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram plugin. No IDE docs bundle. No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json bin entries updated). ## Known limitations (flagged for reviewer) - olp restart not unit-tested (would require mocking child_process.spawn in invasive way; manual smoke-test only at this D-day) - olp logs --level filtering matches optional level field if present in audit-event objects; appendAuditEvent already populates it where meaningful — no schema change needed in this bundle - olp usage panel shape inferred from lib/audit-query.mjs exports; if /v0/management/dashboard-data wire shape differs in subtle ways, formatter degrades to '?' but --json always works ## Authority - ADR 0010 § Phase 4 D-day plan D64-D67 line - ADR 0002 Amendment 7 (this commit — new amendment) - OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference, translated to Node) - OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs (framework reference) - 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2: olp doctor machine-readable next_action) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in. P2-1 — Shell-quote interpolated paths in fix_commands. lib/doctor.mjs config.exists fix_commands previously interpolated ${olpHome} / ${configPath} unquoted into the printf template. A malicious OLP_HOME env value containing shell metacharacters could inject commands into the suggested-fix string an AI agent (or human) pastes back into a terminal. Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for embedded single quotes per POSIX shell rules). Risk surface is narrow at family scale (operator local env, single-user proxy), but hardening cost is one helper. P2-2 — Document launchctl kickstart -k env-stale pitfall. cmdRestart header now carries an explicit caveat that `launchctl kickstart -k` does NOT re-read the plist EnvironmentVariables block — launchd uses cached env from the most recent bootstrap. This is a known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The comment documents the bootout/bootstrap dance for env reloads and notes that the Phase 4 installer (post-D73) will expose `olp restart --full` for the safer reload path. 658/658 tests still pass; the _shellQuote change is invisible to existing tests because the test fixtures use safe paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
577 lines
22 KiB
JavaScript
577 lines
22 KiB
JavaScript
/**
|
|
* 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<name, plugin> — 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.<name> = 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<name, plugin> for tests
|
|
* @param {object} [opts.providersEnabled] — Record<string, boolean>; 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<string, boolean>
|
|
* @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<DoctorResult>}
|
|
*/
|
|
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 <filter>: restrict to checks whose id OR category startsWith / equals the filter.
|
|
// Match rule: exact id match, exact category match, OR id startsWith `<filter>.`
|
|
// (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
|
|
*/
|