Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
504 changes: 504 additions & 0 deletions src/cli/dispatch.ts

Large diffs are not rendered by default.

502 changes: 33 additions & 469 deletions src/cli/index.ts

Large diffs are not rendered by default.

21 changes: 12 additions & 9 deletions tests/cli-ready.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
41 changes: 24 additions & 17 deletions tests/cli-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
Expand All @@ -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([
Expand Down
12 changes: 6 additions & 6 deletions tests/codex-app-server-processes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions tests/codex-retained-root-serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
5 changes: 3 additions & 2 deletions tests/grok-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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))");
Expand Down
5 changes: 3 additions & 2 deletions tests/stale-state-purge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
});
Expand Down
7 changes: 5 additions & 2 deletions tests/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/update-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
8 changes: 4 additions & 4 deletions tests/update-stop-first.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/windows-deploy-close-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading