diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 0000000000..55a619a80e --- /dev/null +++ b/src/cli/dispatch.ts @@ -0,0 +1,504 @@ +/** + * Registry-driven command dispatch (Phase 3 of the CLI deepening). + * + * The command switch moved out of src/cli/index.ts into a runner table keyed + * by command name. Aliases resolve through the registry's alias pairs + * (init/setup, restore/eject, uninstall/remove, models/model); the registry + * remains the single source of command metadata. index.ts passes its local + * helpers (start/stop/ensure/status/...) through CliDispatchDeps so dispatch + * never needs to import the entry module back (no cycle). + */ +import { CLI_COMMANDS } from "./registry"; +import type { CliHead } from "./root"; +import type { ReadyArgs } from "./ready"; +import type { LiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; +import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; +import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch"; +import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; +import { syncModelsToCodex } from "../codex/sync"; +import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; +import { restoreNativeCodexAsync } from "../codex/inject"; +import { stripGrokConfig } from "../grok/inject"; +import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; +import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; +import { serviceCommand } from "../service"; + +export interface CliDispatchDeps { + args: string[]; + command: string | undefined; + head: CliHead; + loadConfig: () => OcxConfig; + findLiveProxy: () => Promise; + probeHostname: (hostname: string | undefined) => string; + waitForProxy: (timeoutMs?: number) => Promise; + startArgv: (port?: number) => string[]; + /** Spawn a detached proxy child (stdio ignore, unref'd, provenance env). */ + spawnDetached: (argv: readonly string[]) => void; + handleStart: () => Promise; + handleStop: () => Promise; + handleEnsure: (options?: { existingIsSuccess?: boolean }) => Promise; + handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; + handleTrayProxyRestart: () => Promise; + handleRestartStartWhenStopped: () => Promise; + handleProxyRestart: (startWhenStopped: () => Promise) => Promise; + handleUninstall: () => Promise; + handleStatus: () => Promise; + handleRecoverHistory: () => Promise; + handleReady: (args: ReadyArgs) => Promise; +} + +type CommandRunner = (deps: CliDispatchDeps) => Promise; + +async function runInternalTrayCommand(deps: CliDispatchDeps): Promise { + await dispatchInternalCliCommand(deps.command as InternalCliCommand, { + trayStart: async () => { await deps.handleTrayProxyStart(); }, + trayRestart: deps.handleTrayProxyRestart, + startupHealth: async () => { + const { collectStartupHealth } = await import("../codex/autostart-health"); + console.log(JSON.stringify(collectStartupHealth(deps.loadConfig()))); + }, + }); +} + +const commandRunners: Record = { + init: async () => { + const { runInit } = await import("./init"); + await runInit(); + }, + start: async deps => { + await deps.handleStart(); + }, + stop: async deps => { + // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers + // re-start the proxy immediately, so warning there would contradict the next line. + if (await deps.handleStop()) { + console.log("⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start')."); + } + }, + restore: async deps => { + const restoreJson = deps.args[1] === "--json"; + if (deps.args[1] === "back") { + // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its + // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged; + // this only re-runs the same inject (config + catalog + history) `ocx start` does. + const live = await deps.findLiveProxy(); + if (!live) { + console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); + process.exit(1); + } + const desired = setIntegrationEnabled("codex", true); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + console.error(`Codex desired state was not saved (${desired.reason}).`); + return; + } + const synced = await syncModelsToCodex(live.port); + if (synced.status === "skipped") { + process.exitCode = 2; + console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes."); + return; + } + if (!synced.ok) { + process.exitCode = 1; + console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); + return; + } + const target = collectOrcaCodexHomeDiagnostic(); + console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); + return; + } + const desired = setIntegrationEnabled("codex", false); + if (!desired.ok) { + process.exitCode = desired.reason === "conflict" ? 2 : 1; + if (restoreJson) { + // Machine-readable contract: every restore --json outcome emits one + // schema-complete envelope on stdout, including pre-machinery failures. + const { skippedRestoreEnvelope } = await import("../codex/inject"); + console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`))); + } else { + console.error(`Codex desired state was not saved (${desired.reason}).`); + } + return; + } + // A repeated OFF on an already-clean home is a policy no-op. Do not enter + // restore's native-profile machinery merely to prove there is nothing to + // restore: those locks live in CODEX_HOME and a skip must create nothing. + if (desired.status === "unchanged") { + const { classifyNativeRoutedResidue } = await import("../codex/native-residue"); + if (classifyNativeRoutedResidue().kind === "clean") { + const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; + if (restoreJson) { + const { skippedRestoreEnvelope } = await import("../codex/inject"); + console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff))); + } else { + console.log(alreadyOff); + } + return; + } + } + let r: { success: boolean; message: string }; + try { + r = await restoreNativeCodexAsync({ revalidateDesiredState: true }); + } catch (err) { + r = { success: false, message: err instanceof Error ? err.message : String(err) }; + } + if (restoreJson) { + // Spawned callers need the artifact-level result to distinguish a busy + // history worker from a successful native restore. Keep stdout machine + // readable; human framing remains the default command contract. + console.log(JSON.stringify(r)); + if (!r.success) process.exitCode = 1; + return; + } + if (r.success) console.log(`✅ ${r.message}`); + else { + console.error(`⚠️ ${r.message}`); + process.exitCode = 1; + } + try { + const g = stripGrokConfig(); + if (g.changed) console.log(`✅ ${g.message}`); + else if (!g.ok) { + console.error(`⚠️ ${g.message}`); + process.exitCode = 1; + } + } catch { /* best-effort */ } + if (r.success) { + console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); + } else { + console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); + } + }, + "recover-history": async deps => { + await deps.handleRecoverHistory(); + }, + uninstall: async deps => { + await deps.handleUninstall(); + }, + status: async deps => { + await deps.handleStatus(); + }, + doctor: async deps => { + const { runDoctor } = await import("./doctor"); + await runDoctor(deps.args.slice(1)); + }, + debug: async deps => { + const { handleDebugCommand } = await import("./debug"); + await handleDebugCommand(deps.args.slice(1)); + }, + ensure: async deps => { + await deps.handleEnsure(); + }, + login: async deps => { + const { handleLogin } = await import("../oauth/login-cli"); + await handleLogin(deps.args[1]); + }, + logout: async deps => { + const { removeCredential } = await import("../oauth/store"); + const name = (deps.args[1] ?? "").trim().toLowerCase(); + await removeCredential(name); + console.log(`Logged out of ${name || "(none)"}.`); + }, + sync: async deps => { + const restartCodex = deps.args.slice(1).includes("--restart-codex"); + const synced = await syncModelsToCodex((await deps.findLiveProxy())?.port); + if (synced.status === "skipped") { + console.log("Codex integration is OFF; sync skipped and no Codex files changed."); + } else if (!synced.ok) { + process.exitCode = 1; + console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); + } + // Only warn/restart when a catalog or models_cache write actually happened. This is + // deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig, + // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is + // exactly when a long-lived app-server is holding the stale list. + if (synced.catalogWritten || synced.cacheSynced) { + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + } + }, + v2: async deps => { + const { cmdV2 } = await import("./v2"); + process.exitCode = await cmdV2(deps.args.slice(1), {}, async () => (await deps.findLiveProxy())?.port); + }, + "sync-cache": async deps => { + const restartCodex = deps.args.slice(1).includes("--restart-codex"); + if (!shouldSyncCodexOnStart(deps.loadConfig())) { + console.log("Codex integration is OFF; cache sync skipped and no Codex files changed."); + return; + } + const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); + const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); + const { getCodexHome } = await import("../codex/paths"); + const owningCodexHome = getCodexHome(); + const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => + invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)); + // Only warn/restart when models_cache was actually rewritten from a readable catalog. + if (invalidated.kind === "completed" && invalidated.value) { + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + } + }, + gui: async deps => { + const config = deps.loadConfig(); + // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port + // proxy and waits until the spawned one actually answers before opening the browser. + let live = await deps.findLiveProxy(); + if (!live) { + console.log("Proxy not running. Starting..."); + deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined)); + live = await deps.waitForProxy(); + if (!live) { + console.error("❌ Proxy did not become healthy after starting. Not opening the GUI."); + process.exit(1); + } + } + // Open the host the proxy actually binds — `localhost` only answers for + // loopback/wildcard binds, not a concrete LAN/IPv6 hostname. + const guiHost = deps.probeHostname(live?.hostname ?? config.hostname); + const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`; + console.log(`Opening ${guiUrl}`); + const { openUrl } = await import("../lib/open-url"); + openUrl(guiUrl); + }, + service: async deps => { + await serviceCommand(...deps.args.slice(1)); + }, + tray: async deps => { + const { windowsTrayCommand } = await import("../tray/windows"); + await windowsTrayCommand(deps.args.slice(1)); + }, + "codex-shim": async deps => { + const { codexShimStatus, diagnoseCodexShim, installCodexShim, uninstallCodexShim } = await import("../codex/shim"); + switch (deps.args[1]) { + case "install": { + const r = installCodexShim(); + const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); + const warnings = diagnoseCodexShim().healthy + ? collectCodexShimReadinessWarnings() + : []; + console.log(`${r.installed && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`); + for (const warning of warnings) console.warn(` ${warning}`); + break; + } + case "status": + console.log(codexShimStatus()); + break; + case "uninstall": + case "remove": { + const r = uninstallCodexShim(); + console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`); + break; + } + default: + console.error("Usage: ocx codex-shim "); + process.exit(1); + } + }, + update: async deps => { + // `ocx update --help` must print usage and exit WITHOUT side effects — running the + // real self-update stops the proxy and drops in-flight routed streams (issue #168). + if (hasHelpFlag(deps.args.slice(1))) { + printSubcommandUsage("update"); + return; + } + const { runUpdate } = await import("../update"); + await runUpdate(); + }, + "__refresh-version": async deps => { + // Hidden, detached helper spawned by the update prompt to refresh the + // cached latest version without blocking the foreground start. Not in help. + const { refreshVersionCache } = await import("../update/notify"); + const channel = deps.args[1] === "preview" ? "preview" : "latest"; + await refreshVersionCache(channel); + }, + "__tray-start": runInternalTrayCommand, + "__tray-restart": runInternalTrayCommand, + "__startup-health": runInternalTrayCommand, + "__tray-host": async () => { + const { runWindowsTrayHost } = await import("../tray/windows"); + await runWindowsTrayHost(); + }, + "__gui-update-worker": async deps => { + const jobId = deps.args[1]; + if (!jobId) process.exit(1); + const channel = normalizeUpdateChannel(deps.args[2]); + await runGuiUpdateWorker(jobId, channel, deps.args[3] === "restart"); + }, + restart: async deps => { + // The running proxy owns its drain and replacement through /api/system/restart. + // If nothing is live, restart degrades to the documented `ensure` start behavior. + await deps.handleProxyRestart(deps.handleRestartStartWhenStopped); + }, + health: async deps => { + const healthArgs = deps.args.slice(1); + const wantsHealthJson = healthArgs.includes("--json"); + const live = await deps.findLiveProxy(); + if (wantsHealthJson) { + console.log(JSON.stringify({ ok: !!live, pid: live?.pid ?? null, port: live?.port ?? null })); + } else { + console.log(live ? `Proxy healthy (PID ${live.pid}, port ${live.port})` : "Proxy not healthy"); + } + process.exit(live ? 0 : 1); + }, + ready: async deps => { + // Fail-closed impossible-state guard: readyArgs is populated by the + // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShim, so + // reaching here without it means dispatch diverged. Refuse with code 64 + // and perform NO I/O (no discovery/probe). process.exit is `never`, + // narrowing below. + const readyArgs = deps.head.readyArgs; + if (!readyArgs) process.exit(64); + await deps.handleReady(readyArgs); + }, + provider: async deps => { + const { handleProviderCommand } = await import("./provider"); + await handleProviderCommand(deps.args.slice(1)); + }, + account: async deps => { + const { cmdAccount } = await import("./account"); + process.exitCode = await cmdAccount(deps.args.slice(1)); + }, + models: async deps => { + const { handleModels } = await import("./models"); + await handleModels(deps.args.slice(1)); + }, + combo: async deps => { + const { handleComboCommand } = await import("./combo"); + process.exitCode = await handleComboCommand(deps.args.slice(1)); + }, + route: async deps => { + if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { + console.error("Usage: ocx route "); + process.exitCode = 2; + return; + } + if (deps.args[1] === "combo") { + const { handleComboCommand } = await import("./combo"); + process.exitCode = await handleComboCommand(deps.args.slice(2)); + } else { + const { handleRoutePolicyCommand } = await import("./route-policy"); + process.exitCode = await handleRoutePolicyCommand(deps.args.slice(2)); + } + }, + agent: async deps => { + const { handleAgentCommand } = await import("./agent"); + process.exitCode = await handleAgentCommand(deps.args.slice(1)); + }, + observe: async deps => { + const { handleObserveCommand } = await import("./observe"); + process.exitCode = await handleObserveCommand(deps.args.slice(1)); + }, + logs: async deps => { + const { handleObserveCommand } = await import("./observe"); + process.exitCode = await handleObserveCommand([deps.command!, ...deps.args.slice(1)]); + }, + usage: async deps => { + const { handleObserveCommand } = await import("./observe"); + process.exitCode = await handleObserveCommand([deps.command!, ...deps.args.slice(1)]); + }, + storage: async deps => { + const { handleObserveCommand } = await import("./observe"); + process.exitCode = await handleObserveCommand([deps.command!, ...deps.args.slice(1)]); + }, + memory: async deps => { + const { handleObserveCommand } = await import("./observe"); + process.exitCode = await handleObserveCommand([deps.command!, ...deps.args.slice(1)]); + }, + access: async deps => { + const { handleAccessCommand } = await import("./access"); + process.exitCode = await handleAccessCommand(deps.args.slice(1)); + }, + "api-key": async deps => { + const { handleAccessCommand } = await import("./access"); + process.exitCode = await handleAccessCommand(["key", ...deps.args.slice(1)]); + }, + export: async deps => { + const { handleExportCommand } = await import("./export-command"); + process.exitCode = await handleExportCommand(deps.args.slice(1)); + }, + grok: async deps => { + const { handleGrokCommand } = await import("./integrations"); + process.exitCode = await handleGrokCommand(deps.args.slice(1)); + }, + integration: async deps => { + const integration = deps.args[1]; + if (integration === "grok") { + const { handleGrokCommand } = await import("./integrations"); + process.exitCode = await handleGrokCommand(deps.args.slice(2)); + } else if (integration === "claude") { + const { handleClaudeConfigCommand } = await import("./integrations"); + process.exitCode = await handleClaudeConfigCommand(deps.args.slice(2)); + } else if (integration === "client") { + const { handleClientIntegrationCommand } = await import("./integrations"); + process.exitCode = await handleClientIntegrationCommand(deps.args.slice(2)); + } else { + console.error("Usage: ocx integration "); + process.exitCode = 2; + } + }, + system: async deps => { + const { handleSystemCommand } = await import("./system-command"); + process.exitCode = await handleSystemCommand(deps.args.slice(1)); + }, + config: async deps => { + const { handleConfigCommand } = await import("./config-command"); + process.exitCode = await handleConfigCommand(deps.args.slice(1)); + }, + lab: async deps => { + const { handleLabCommand } = await import("./lab"); + process.exitCode = await handleLabCommand(deps.args.slice(1)); + }, + claude: async deps => { + const { cmdClaude } = await import("./claude"); + // "ocx claude desktop" → write Desktop 3P config + if (deps.args[1] === "desktop") { + const { handleClaudeDesktopCommand } = await import("./claude-desktop"); + const exitCode = await handleClaudeDesktopCommand(deps.args.slice(2)); + if (exitCode !== 0) process.exit(exitCode); + return; + } + if (deps.args[1] === "config") { + const { handleClaudeConfigCommand } = await import("./integrations"); + process.exitCode = await handleClaudeConfigCommand(deps.args.slice(2)); + return; + } + process.exit(await cmdClaude(deps.args.slice(1))); + }, + opencode: async deps => { + const { cmdOpencode } = await import("./opencode"); + process.exit(await cmdOpencode(deps.args.slice(1))); + }, + help: async () => { + printUsage(); + }, + "--help": async () => { + printUsage(); + }, + "-h": async () => { + printUsage(); + }, +}; + +/** Registry alias pairs → canonical dispatch name (init/setup, restore/eject, …). */ +const aliasTargets = new Map(); +for (const entry of CLI_COMMANDS) { + for (const alias of entry.aliases ?? []) aliasTargets.set(alias, entry.name); +} + +export const DISPATCH_COMMANDS: ReadonlySet = new Set(Object.keys(commandRunners)); +export const DISPATCH_ALIASES: ReadonlyMap = aliasTargets; + +export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Promise { + const command = head.command; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + printUsage(); + return; + } + const runner = commandRunners[command] ?? commandRunners[aliasTargets.get(command) ?? ""]; + if (!runner) { + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } + await runner(deps); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 840989d7b2..2ddc1e627e 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -24,7 +24,7 @@ import { writeRuntimePort, } from "../config"; import { collectStatus } from "./status"; -import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch"; + import { discoverStableProxyForRestart, isProxyReplacement, @@ -35,7 +35,7 @@ import { } from "./tray-proxy"; import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; -import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; +import { dispatchCommand } from "./dispatch"; import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; @@ -56,8 +56,8 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; -import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; -import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; + + import { removeOwnedConfigState } from "../lib/config-ownership"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { initializeNodeLauncherContext } from "./launcher-context"; @@ -912,469 +912,33 @@ async function handleReady(args: ReadyArgs): Promise { process.exit(await runReady(args)); } -switch (command) { - case "init": - case "setup": { - const { runInit } = await import("./init"); - await runInit(); - break; - } - case "start": - await handleStart(); - break; - case "stop": { - // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers - // re-start the proxy immediately, so warning there would contradict the next line. - if (await handleStop()) { - console.log("⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start')."); - } - break; - } - case "restore": - case "eject": { - const restoreJson = args[1] === "--json"; - if (args[1] === "back") { - // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its - // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged; - // this only re-runs the same inject (config + catalog + history) `ocx start` does. - const live = await findLiveProxy(); - if (!live) { - console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); - process.exit(1); - } - const desired = setIntegrationEnabled("codex", true); - if (!desired.ok) { - process.exitCode = desired.reason === "conflict" ? 2 : 1; - console.error(`Codex desired state was not saved (${desired.reason}).`); - break; - } - const synced = await syncModelsToCodex(live.port); - if (synced.status === "skipped") { - process.exitCode = 2; - console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes."); - break; - } - if (!synced.ok) { - process.exitCode = 1; - console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); - break; - } - const target = collectOrcaCodexHomeDiagnostic(); - console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); - break; - } - const desired = setIntegrationEnabled("codex", false); - if (!desired.ok) { - process.exitCode = desired.reason === "conflict" ? 2 : 1; - if (restoreJson) { - // Machine-readable contract: every restore --json outcome emits one - // schema-complete envelope on stdout, including pre-machinery failures. - const { skippedRestoreEnvelope } = await import("../codex/inject"); - console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`))); - } else { - console.error(`Codex desired state was not saved (${desired.reason}).`); - } - break; - } - // A repeated OFF on an already-clean home is a policy no-op. Do not enter - // restore's native-profile machinery merely to prove there is nothing to - // restore: those locks live in CODEX_HOME and a skip must create nothing. - if (desired.status === "unchanged") { - const { classifyNativeRoutedResidue } = await import("../codex/native-residue"); - if (classifyNativeRoutedResidue().kind === "clean") { - const alreadyOff = "Codex integration is already OFF and native; no Codex files changed."; - if (restoreJson) { - const { skippedRestoreEnvelope } = await import("../codex/inject"); - console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff))); - } else { - console.log(alreadyOff); - } - break; - } - } - let r: { success: boolean; message: string }; - try { - r = await restoreNativeCodexAsync({ revalidateDesiredState: true }); - } catch (err) { - r = { success: false, message: err instanceof Error ? err.message : String(err) }; - } - if (restoreJson) { - // Spawned callers need the artifact-level result to distinguish a busy - // history worker from a successful native restore. Keep stdout machine - // readable; human framing remains the default command contract. - console.log(JSON.stringify(r)); - if (!r.success) process.exitCode = 1; - break; - } - if (r.success) console.log(`✅ ${r.message}`); - else { - console.error(`⚠️ ${r.message}`); - process.exitCode = 1; - } - try { - const g = stripGrokConfig(); - if (g.changed) console.log(`✅ ${g.message}`); - else if (!g.ok) { - console.error(`⚠️ ${g.message}`); - process.exitCode = 1; - } - } catch { /* best-effort */ } - if (r.success) { - console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); - } else { - console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); - } - break; - } - case "recover-history": - await handleRecoverHistory(); - break; - case "uninstall": - case "remove": - await handleUninstall(); - break; - case "status": - await handleStatus(); - break; - case "doctor": { - const { runDoctor } = await import("./doctor"); - await runDoctor(args.slice(1)); - break; - } - case "debug": { - const { handleDebugCommand } = await import("./debug"); - await handleDebugCommand(args.slice(1)); - break; - } - case "ensure": - await handleEnsure(); - break; - case "login": { - const { handleLogin } = await import("../oauth/login-cli"); - await handleLogin(args[1]); - break; - } - case "logout": { - const { removeCredential } = await import("../oauth/store"); - const name = (args[1] ?? "").trim().toLowerCase(); - await removeCredential(name); - console.log(`Logged out of ${name || "(none)"}.`); - break; - } - case "sync": { - const restartCodex = args.slice(1).includes("--restart-codex"); - const synced = await syncModelsToCodex((await findLiveProxy())?.port); - if (synced.status === "skipped") { - console.log("Codex integration is OFF; sync skipped and no Codex files changed."); - } else if (!synced.ok) { - process.exitCode = 1; - console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); - } - // Only warn/restart when a catalog or models_cache write actually happened. This is - // deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig, - // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is - // exactly when a long-lived app-server is holding the stale list. - if (synced.catalogWritten || synced.cacheSynced) { - const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); - } - break; - } - case "v2": { - const { cmdV2 } = await import("./v2"); - process.exitCode = await cmdV2(args.slice(1), {}, async () => (await findLiveProxy())?.port); - break; - } - case "sync-cache": { - const restartCodex = args.slice(1).includes("--restart-codex"); - if (!shouldSyncCodexOnStart(loadConfig())) { - console.log("Codex integration is OFF; cache sync skipped and no Codex files changed."); - break; - } - const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); - const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); - const { getCodexHome } = await import("../codex/paths"); - const owningCodexHome = getCodexHome(); - const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => - invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)); - // Only warn/restart when models_cache was actually rewritten from a readable catalog. - if (invalidated.kind === "completed" && invalidated.value) { - const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); - } - break; - } - case "gui": { - const cfg = await import("../config"); - const config = cfg.loadConfig(); - // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port - // proxy and waits until the spawned one actually answers before opening the browser. - let live = await findLiveProxy(); - if (!live) { - console.log("Proxy not running. Starting..."); - const child = spawn(process.execPath, startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined), { - detached: true, - stdio: "ignore", - windowsHide: true, - env: withProcessRuntimeProvenance(process.env), - }); - child.unref(); - live = await waitForProxy(); - if (!live) { - console.error("❌ Proxy did not become healthy after starting. Not opening the GUI."); - process.exit(1); - } - } - // Open the host the proxy actually binds — `localhost` only answers for - // loopback/wildcard binds, not a concrete LAN/IPv6 hostname. - const guiHost = probeHostname(live?.hostname ?? config.hostname); - const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`; - console.log(`Opening ${guiUrl}`); - const { openUrl } = await import("../lib/open-url"); - openUrl(guiUrl); - break; - } - case "service": - await serviceCommand(...args.slice(1)); - break; - case "tray": { - const { windowsTrayCommand } = await import("../tray/windows"); - await windowsTrayCommand(args.slice(1)); - break; - } - case "codex-shim": { - const { codexShimStatus, diagnoseCodexShim, installCodexShim, uninstallCodexShim } = await import("../codex/shim"); - switch (args[1]) { - case "install": { - const r = installCodexShim(); - const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); - const warnings = diagnoseCodexShim().healthy - ? collectCodexShimReadinessWarnings() - : []; - console.log(`${r.installed && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`); - for (const warning of warnings) console.warn(` ${warning}`); - break; - } - case "status": - console.log(codexShimStatus()); - break; - case "uninstall": - case "remove": { - const r = uninstallCodexShim(); - console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`); - break; - } - default: - console.error("Usage: ocx codex-shim "); - process.exit(1); - } - break; - } - case "update": { - // `ocx update --help` must print usage and exit WITHOUT side effects — running the - // real self-update stops the proxy and drops in-flight routed streams (issue #168). - if (hasHelpFlag(args.slice(1))) { - printSubcommandUsage("update"); - break; - } - const { runUpdate } = await import("../update"); - await runUpdate(); - break; - } - case "__refresh-version": { - // Hidden, detached helper spawned by the update prompt to refresh the - // cached latest version without blocking the foreground start. Not in help. - const { refreshVersionCache } = await import("../update/notify"); - const channel = args[1] === "preview" ? "preview" : "latest"; - await refreshVersionCache(channel); - break; - } - case "__tray-start": - case "__tray-restart": - case "__startup-health": - await dispatchInternalCliCommand(command as InternalCliCommand, { - trayStart: async () => { await handleTrayProxyStart(); }, - trayRestart: handleTrayProxyRestart, - startupHealth: async () => { - const { collectStartupHealth } = await import("../codex/autostart-health"); - console.log(JSON.stringify(collectStartupHealth(loadConfig()))); - }, +await dispatchCommand(head, { + args, + command, + head, + loadConfig, + findLiveProxy, + probeHostname, + waitForProxy, + startArgv, + spawnDetached: argv => { + const child = spawn(process.execPath, argv, { + detached: true, + stdio: "ignore", + windowsHide: true, + env: withProcessRuntimeProvenance(process.env), }); - break; - case "__tray-host": { - const { runWindowsTrayHost } = await import("../tray/windows"); - await runWindowsTrayHost(); - break; - } - case "__gui-update-worker": { - const jobId = args[1]; - if (!jobId) process.exit(1); - const channel = normalizeUpdateChannel(args[2]); - await runGuiUpdateWorker(jobId, channel, args[3] === "restart"); - break; - } - case "restart": { - // The running proxy owns its drain and replacement through /api/system/restart. - // If nothing is live, restart degrades to the documented `ensure` start behavior. - await handleProxyRestart(handleRestartStartWhenStopped); - break; - } - case "health": { - const healthArgs = args.slice(1); - const wantsHealthJson = healthArgs.includes("--json"); - const live = await findLiveProxy(); - if (wantsHealthJson) { - console.log(JSON.stringify({ ok: !!live, pid: live?.pid ?? null, port: live?.port ?? null })); - } else { - console.log(live ? `Proxy healthy (PID ${live.pid}, port ${live.port})` : "Proxy not healthy"); - } - process.exit(live ? 0 : 1); - } - case "ready": { - // Fail-closed impossible-state guard: readyArgs is populated by the - // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShim, so - // reaching here without it means dispatch diverged. Refuse with code 64 - // and perform NO I/O (no discovery/probe). process.exit is `never`, - // narrowing below. - const readyArgs = head.readyArgs; - if (!readyArgs) process.exit(64); - await handleReady(readyArgs); - break; - } - case "provider": { - const { handleProviderCommand } = await import("./provider"); - await handleProviderCommand(args.slice(1)); - break; - } - case "account": { - const { cmdAccount } = await import("./account"); - process.exitCode = await cmdAccount(args.slice(1)); - break; - } - case "models": - case "model": { - const { handleModels } = await import("./models"); - await handleModels(args.slice(1)); - break; - } - case "combo": { - const { handleComboCommand } = await import("./combo"); - process.exitCode = await handleComboCommand(args.slice(1)); - break; - } - case "route": { - if (args[1] !== "combo" && args[1] !== "policy") { - console.error("Usage: ocx route "); - process.exitCode = 2; - break; - } - if (args[1] === "combo") { - const { handleComboCommand } = await import("./combo"); - process.exitCode = await handleComboCommand(args.slice(2)); - } else { - const { handleRoutePolicyCommand } = await import("./route-policy"); - process.exitCode = await handleRoutePolicyCommand(args.slice(2)); - } - break; - } - case "agent": { - const { handleAgentCommand } = await import("./agent"); - process.exitCode = await handleAgentCommand(args.slice(1)); - break; - } - case "observe": { - const { handleObserveCommand } = await import("./observe"); - process.exitCode = await handleObserveCommand(args.slice(1)); - break; - } - case "logs": - case "usage": - case "storage": - case "memory": { - const { handleObserveCommand } = await import("./observe"); - process.exitCode = await handleObserveCommand([command, ...args.slice(1)]); - break; - } - case "access": { - const { handleAccessCommand } = await import("./access"); - process.exitCode = await handleAccessCommand(args.slice(1)); - break; - } - case "api-key": { - const { handleAccessCommand } = await import("./access"); - process.exitCode = await handleAccessCommand(["key", ...args.slice(1)]); - break; - } - case "export": { - const { handleExportCommand } = await import("./export-command"); - process.exitCode = await handleExportCommand(args.slice(1)); - break; - } - case "grok": { - const { handleGrokCommand } = await import("./integrations"); - process.exitCode = await handleGrokCommand(args.slice(1)); - break; - } - case "integration": { - const integration = args[1]; - if (integration === "grok") { - const { handleGrokCommand } = await import("./integrations"); - process.exitCode = await handleGrokCommand(args.slice(2)); - } else if (integration === "claude") { - const { handleClaudeConfigCommand } = await import("./integrations"); - process.exitCode = await handleClaudeConfigCommand(args.slice(2)); - } else if (integration === "client") { - const { handleClientIntegrationCommand } = await import("./integrations"); - process.exitCode = await handleClientIntegrationCommand(args.slice(2)); - } else { - console.error("Usage: ocx integration "); - process.exitCode = 2; - } - break; - } - case "system": { - const { handleSystemCommand } = await import("./system-command"); - process.exitCode = await handleSystemCommand(args.slice(1)); - break; - } - case "config": { - const { handleConfigCommand } = await import("./config-command"); - process.exitCode = await handleConfigCommand(args.slice(1)); - break; - } - case "lab": { - const { handleLabCommand } = await import("./lab"); - process.exitCode = await handleLabCommand(args.slice(1)); - break; - } - case "claude": { - const { cmdClaude } = await import("./claude"); - // "ocx claude desktop" → write Desktop 3P config - if (args[1] === "desktop") { - const { handleClaudeDesktopCommand } = await import("./claude-desktop"); - const exitCode = await handleClaudeDesktopCommand(args.slice(2)); - if (exitCode !== 0) process.exit(exitCode); - break; - } - if (args[1] === "config") { - const { handleClaudeConfigCommand } = await import("./integrations"); - process.exitCode = await handleClaudeConfigCommand(args.slice(2)); - break; - } - process.exit(await cmdClaude(args.slice(1))); - } - case "opencode": { - const { cmdOpencode } = await import("./opencode"); - process.exit(await cmdOpencode(args.slice(1))); - } - case "help": - case "--help": - case "-h": - case undefined: - printUsage(); - break; - default: - console.error(`Unknown command: ${command}`); - printUsage(); - process.exit(1); -} + child.unref(); + }, + handleStart, + handleStop, + handleEnsure, + handleTrayProxyStart, + handleTrayProxyRestart, + handleRestartStartWhenStopped, + handleProxyRestart, + handleUninstall, + handleStatus, + handleRecoverHistory, + handleReady, +}); diff --git a/tests/cli-ready.test.ts b/tests/cli-ready.test.ts index 0adbd84039..e1bfdd5baa 100644 --- a/tests/cli-ready.test.ts +++ b/tests/cli-ready.test.ts @@ -728,18 +728,21 @@ describe("ready pre-parse before maybeAutoRestoreCodexShim (source-level, P1)", test("valid ready dispatch reaches handleReady AFTER maybeAutoRestoreCodexShim, with fail-closed guard", () => { // Ordering: index.ts awaits runCli (which runs parseCliHead and the shim - // preflight inside root.ts) BEFORE the switch dispatches the ready case. + // preflight inside root.ts) BEFORE dispatch.ts runs the ready runner. const runCliIdx = cliSource.indexOf("await runCli(process.argv.slice(2))"); expect(runCliIdx, "index.ts must await runCli before dispatch").toBeGreaterThanOrEqual(0); - const switchIdx = cliSource.indexOf("switch (command)"); - expect(switchIdx, "the command switch must exist").toBeGreaterThanOrEqual(0); - expect(runCliIdx).toBeLessThan(switchIdx); + const dispatchIdx = cliSource.indexOf("await dispatchCommand(head"); + expect(dispatchIdx, "index.ts must dispatch via dispatchCommand").toBeGreaterThanOrEqual(0); + expect(runCliIdx).toBeLessThan(dispatchIdx); expect(rootSource).toContain("maybeAutoRestoreCodexShim(head.command, head.args)"); - const readyCaseIdx = cliSource.indexOf('case "ready":'); - expect(readyCaseIdx, 'a "ready" switch case must exist').toBeGreaterThanOrEqual(0); - // Slice the whole ready case body (up to the next case), not a fixed width. - const nextCaseIdx = cliSource.indexOf("case ", readyCaseIdx + 1); - const caseBody = cliSource.slice(readyCaseIdx, nextCaseIdx === -1 ? undefined : nextCaseIdx); + // The ready runner lives in dispatch.ts (keyed "ready:"); slice its body + // up to the next runner key, not a fixed width. + const dispatchSource = readFileSync(join(import.meta.dir, "../src/cli/dispatch.ts"), "utf8"); + const readyCaseIdx = dispatchSource.indexOf("ready: async"); + expect(readyCaseIdx, 'a "ready" runner must exist in dispatch.ts').toBeGreaterThanOrEqual(0); + // The ready runner is followed by the provider runner; slice to that key. + const nextCaseIdx = dispatchSource.indexOf("provider: async", readyCaseIdx + 1); + const caseBody = dispatchSource.slice(readyCaseIdx, nextCaseIdx === -1 ? undefined : nextCaseIdx); // Passes the stashed readyArgs; fail-closed guard exits 64 with NO I/O if // the impossible state (missing pre-parsed args) ever occurs. expect(caseBody).toContain("readyArgs"); diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index 8de6473319..be2b2eb4ae 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -1,34 +1,32 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { CLI_COMMANDS, findCommand } from "../src/cli/registry"; - -const cliSource = readFileSync(join(import.meta.dir, "../src/cli/index.ts"), "utf8"); - -/** Top-level switch case names in src/cli/index.ts. - * Top-level cases sit at 2-4 space indent; the nested codex-shim sub-switch - * cases (install/status/uninstall/remove) sit at 6+ and are not commands. */ -function topLevelSwitchCases(source: string): string[] { - const names: string[] = []; - for (const match of source.matchAll(/^ {2,4}case "([^"]+)"/gm)) names.push(match[1]!); - return names; -} +import { DISPATCH_ALIASES, DISPATCH_COMMANDS } from "../src/cli/dispatch"; describe("CLI command registry parity", () => { - const cases = topLevelSwitchCases(cliSource); + /** Runner keys in src/cli/dispatch.ts (the dispatch table that replaced the + * top-level switch in src/cli/index.ts). */ + const cases = [...DISPATCH_COMMANDS]; const caseSet = new Set(cases); const registryNames = new Set(CLI_COMMANDS.flatMap(entry => [entry.name, ...(entry.aliases ?? [])])); test("every top-level switch case resolves in the registry", () => { - // `help`/`--help`/`-h` are head-handled pseudo-cases, not commands. + // `help`/`--help`/`-h` are head-handled pseudo-cases, not commands. They + // still exist as dispatch runners so a bare `ocx help` reaches printUsage, + // but they are not registry entries; exclude them only while dispatch does + // not list them as commands. const headHandled = new Set(["help", "--help", "-h"]); - const unresolvable = cases.filter(name => !headHandled.has(name) && !registryNames.has(name)); + const unresolvable = cases.filter(name => !(headHandled.has(name) && !registryNames.has(name)) && !registryNames.has(name)); expect(unresolvable).toEqual([]); }); test("every registry entry has a top-level switch case", () => { + // Every canonical entry.name must be a direct runner key in the dispatch + // table (a missing canonical case must never pass via an alias). Alias + // entries (setup/eject/remove/model) are not standalone runner keys; they + // resolve through DISPATCH_ALIASES and are asserted separately below. + const aliasNames = new Set([...DISPATCH_ALIASES.keys()]); const missing = CLI_COMMANDS - .filter(entry => !caseSet.has(entry.name)) + .filter(entry => !aliasNames.has(entry.name) && !caseSet.has(entry.name)) .map(entry => entry.name); expect(missing).toEqual([]); }); @@ -53,6 +51,15 @@ describe("CLI command registry parity", () => { expect(aliasesOf("models")).toContain("model"); }); + test("every declared alias resolves through DISPATCH_ALIASES to a runner key", () => { + for (const entry of CLI_COMMANDS) { + for (const alias of entry.aliases ?? []) { + expect(DISPATCH_ALIASES.get(alias), `alias ${alias} must resolve`).toBe(entry.name); + expect(caseSet.has(entry.name), `canonical ${entry.name} must be a runner key`).toBe(true); + } + } + }); + test("hidden entries are flagged and do not appear in help lookups by accident", () => { const hidden = CLI_COMMANDS.filter(entry => entry.hidden); expect(hidden.map(entry => entry.name).sort()).toEqual([ diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 55d6f5617e..da7a7302bc 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -340,15 +340,15 @@ describe("Codex app-server process matching (#476)", () => { }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { - const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); + const dispatchSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "dispatch.ts"), "utf8"); const configRoutesSource = readFileSync( join(import.meta.dir, "..", "src", "server", "management", "config-routes.ts"), "utf8", ); test("ocx sync only handles app-servers after a catalog/cache write and forwards --restart-codex", () => { - const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); - expect(syncCase).toContain('args.slice(1).includes("--restart-codex")'); + const syncCase = dispatchSource.slice(dispatchSource.indexOf("sync: async"), dispatchSource.indexOf("v2: async")); + expect(syncCase).toContain('deps.args.slice(1).includes("--restart-codex")'); expect(syncCase).toContain("synced.catalogWritten || synced.cacheSynced"); expect(syncCase).toContain("afterCatalogWriteHandleAppServers"); expect(syncCase).toContain("restart: restartCodex"); @@ -361,9 +361,9 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { }); test("ocx sync-cache only handles app-servers after a successful models_cache write", () => { - const syncCacheCase = cliSource.slice( - cliSource.indexOf('case "sync-cache":'), - cliSource.indexOf('case "gui":'), + const syncCacheCase = dispatchSource.slice( + dispatchSource.indexOf('"sync-cache": async'), + dispatchSource.indexOf("gui: async"), ); // The cache write now happens under the catalog serialization lock K, so the // gate reads the permitted writer's outcome instead of a bare boolean call. diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index a934ce3651..e629dce1fb 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -185,9 +185,9 @@ test("startup and CLI sync-cache cannot write models_cache while another process }); expect(cli.exitCode).toBe(0); expect(existsSync(cachePath)).toBe(false); - const cliSource = readFileSync(join(repoRoot, "src/cli/index.ts"), "utf8"); - const cliStart = cliSource.indexOf('case "sync-cache"'); - const cliRoot = cliSource.slice(cliStart, cliSource.indexOf('case "gui"', cliStart)); + const cliSource = readFileSync(join(repoRoot, "src/cli/dispatch.ts"), "utf8"); + const cliStart = cliSource.indexOf('"sync-cache": async'); + const cliRoot = cliSource.slice(cliStart, cliSource.indexOf('gui: async', cliStart)); expect(cliRoot).toContain("withCatalogWriteSerialization(owningCodexHome"); expect(cliRoot).toContain("invalidateCodexModelsCacheWithPermit"); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index b7fdc351f0..b11e426e86 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { isServiceOwnershipError, ServiceOwnershipError } from "../src/service"; const CLI_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); +const DISPATCH_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "dispatch.ts"), "utf8"); const SERVICE_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); const MANAGEMENT_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "server", "management-api.ts"), "utf8"); const PROCESS_CONTROL_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "lib", "process-control.ts"), "utf8"); @@ -92,8 +93,8 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("return !stopFailed"); expect(stopFn).not.toContain("process.exit(1)"); - const restartCase = sliceFn(CLI_SOURCE, 'case "restart"', 'case "health"'); - expect(restartCase).toContain("await handleProxyRestart(handleRestartStartWhenStopped)"); + const restartCase = sliceFn(DISPATCH_SOURCE, "restart: async", "health: async"); + expect(restartCase).toContain("await deps.handleProxyRestart(deps.handleRestartStartWhenStopped)"); const trayRestart = sliceFn(CLI_SOURCE, "async function handleTrayProxyRestart(", "async function restoreSharedClientStateAfterStop("); const restartHelper = sliceFn(CLI_SOURCE, "async function handleProxyRestart(", "async function handleTrayProxyRestart("); expect(trayRestart).toContain("await handleProxyRestart(() => handleTrayProxyStart(false))"); diff --git a/tests/stale-state-purge.test.ts b/tests/stale-state-purge.test.ts index a2ff1cf3f9..73ab5ccf9e 100644 --- a/tests/stale-state-purge.test.ts +++ b/tests/stale-state-purge.test.ts @@ -67,8 +67,9 @@ describe("snapshot-guarded stale-state purge", () => { test("gui opens the actual bind host and recover-history surfaces a locked DB", () => { const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); - expect(cliSource).toContain("const guiHost = probeHostname(live?.hostname ?? config.hostname)"); - const recoverFn = cliSource.slice(cliSource.indexOf("function handleRecoverHistory()"), cliSource.indexOf("switch (command)")); + const dispatchSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "dispatch.ts"), "utf8"); + expect(dispatchSource).toContain("const guiHost = deps.probeHostname(live?.hostname ?? config.hostname)"); + const recoverFn = cliSource.slice(cliSource.indexOf("function handleRecoverHistory()"), cliSource.indexOf("await dispatchCommand(head")); expect(recoverFn).toContain("if (r.failed)"); expect(recoverFn).toContain("process.exit(1)"); }); diff --git a/tests/uninstall.test.ts b/tests/uninstall.test.ts index 8f9b9749b1..8988ed3431 100644 --- a/tests/uninstall.test.ts +++ b/tests/uninstall.test.ts @@ -14,9 +14,10 @@ describe("full uninstall command", () => { afterEach(() => setUninstallServiceHooksForTests(null)); test("CLI exposes a one-shot local state cleanup command", async () => { - const cli = await readText("src/cli/index.ts"); + const dispatch = await readText("src/cli/dispatch.ts"); - expect(cli).toContain('case "uninstall"'); + expect(dispatch).toContain("uninstall: async"); + const cli = await readText("src/cli/index.ts"); expect(cli).toContain("async function handleUninstall()"); expect(cli).toContain("uninstallServiceIfInstalled"); expect(cli).toContain("uninstallCodexShim"); @@ -26,8 +27,10 @@ describe("full uninstall command", () => { }); test("CLI exposes explicit legacy history recovery command", async () => { + const dispatch = await readText("src/cli/dispatch.ts"); const cli = await readText("src/cli/index.ts"); + expect(dispatch).toContain('"recover-history": async'); expect(cli).toContain("ocx recover-history --legacy-openai"); expect(cli).toContain("async function handleRecoverHistory()"); // The command still performs legacy recovery, but through the serialized diff --git a/tests/update-notify.test.ts b/tests/update-notify.test.ts index 5976c69452..c145fa0378 100644 --- a/tests/update-notify.test.ts +++ b/tests/update-notify.test.ts @@ -135,8 +135,8 @@ describe("cli wiring", () => { }); test("hidden __refresh-version subcommand is wired", async () => { - const cli = await readText("src/cli/index.ts"); - expect(cli).toContain("case \"__refresh-version\""); - expect(cli).toContain("refreshVersionCache"); + const dispatch = await readText("src/cli/dispatch.ts"); + expect(dispatch).toContain("\"__refresh-version\": async"); + expect(dispatch).toContain("refreshVersionCache"); }); }); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 154d867e5f..45afa13170 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -6,7 +6,7 @@ import { runNpmCachePreflight } from "../src/update/npm-cache-preflight.mjs"; const updateSource = readFileSync(join(import.meta.dir, "..", "src", "update", "index.ts"), "utf8"); const launcherSource = readFileSync(join(import.meta.dir, "..", "bin", "ocx.mjs"), "utf8"); const serverSource = readFileSync(join(import.meta.dir, "..", "src", "server", "index.ts"), "utf8"); -const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); +const dispatchSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "dispatch.ts"), "utf8"); describe("update stops the running proxy before replacing files", () => { test("a failed cache pre-flight aborts before the stop callback can run", () => { @@ -140,9 +140,9 @@ describe("update stops the running proxy before replacing files", () => { describe("ocx update --help has no side effects (#168)", () => { test("the Bun CLI short-circuits help before importing the update runner", () => { - const caseAt = cliSource.indexOf('case "update"'); - const helpAt = cliSource.indexOf('printSubcommandUsage("update")'); - const runAt = cliSource.indexOf("await runUpdate()"); + const caseAt = dispatchSource.indexOf('update: async'); + const helpAt = dispatchSource.indexOf('printSubcommandUsage("update")'); + const runAt = dispatchSource.indexOf("await runUpdate()"); expect(caseAt).toBeGreaterThan(-1); expect(helpAt).toBeGreaterThan(caseAt); expect(helpAt).toBeLessThan(runAt); diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index 41933c22ab..432c65dda8 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -30,7 +30,7 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou expect(src).toContain("runtimeTrusted"); expect(read("src/cli/index.ts")).toContain("allowEphemeralFallback: !hardPin"); expect(read("src/cli/index.ts")).toContain("preferRetryMs: hardPin ? 5_000 : 750"); - expect(read("src/cli/index.ts")).toContain("Not opening the GUI"); + expect(read("src/cli/dispatch.ts")).toContain("Not opening the GUI"); expect(read("src/server/ports.ts")).toContain("allowEphemeralFallback"); }); test("Windows GUI update worker is launched without inheriting the proxy LISTEN socket", () => {