im_wower
·
2026-03-22
config.ts
1import { resolve } from "node:path";
2
3import type {
4 CodexdCliAction,
5 CodexdEnvironment,
6 CodexdResolvedConfig,
7 CodexdRuntimePaths,
8 CodexdServerConfig
9} from "./contracts.js";
10
11export interface CodexdConfigInput {
12 eventCacheSize?: number;
13 eventStreamPath?: string;
14 localApiBase?: string;
15 logsDir?: string;
16 mode?: string;
17 nodeId?: string;
18 repoRoot?: string;
19 serverArgs?: string[];
20 serverCommand?: string;
21 serverCwd?: string;
22 serverEndpoint?: string;
23 serverStrategy?: string;
24 smokeLifetimeMs?: number;
25 stateDir?: string;
26 version?: string | null;
27}
28
29export type CodexdCliRequest =
30 | {
31 action: "help";
32 }
33 | {
34 action: Exclude<CodexdCliAction, "help" | "start">;
35 config: CodexdResolvedConfig;
36 printJson: boolean;
37 }
38 | {
39 action: "start";
40 config: CodexdResolvedConfig;
41 printJson: boolean;
42 runOnce: boolean;
43 lifetimeMs: number;
44 };
45
46const DEFAULT_EVENT_CACHE_SIZE = 50;
47const DEFAULT_EVENT_STREAM_PATH = "/v1/codexd/events";
48const DEFAULT_LOCAL_API_BASE = "http://127.0.0.1:4319";
49const DEFAULT_NODE_ID = "mini-main";
50const DEFAULT_SERVER_ARGS = ["app-server"];
51const DEFAULT_SERVER_COMMAND = "codex";
52const DEFAULT_SERVER_ENDPOINT = "stdio://codex-app-server";
53const DEFAULT_SERVER_MODE = "app-server";
54const DEFAULT_SERVER_STRATEGY = "spawn";
55const DEFAULT_SMOKE_LIFETIME_MS = 100;
56
57export function resolveCodexdConfig(input: CodexdConfigInput = {}): CodexdResolvedConfig {
58 const repoRoot = resolve(input.repoRoot ?? getDefaultRepoRoot());
59 const logsRootDir = resolve(getOptionalString(input.logsDir) ?? resolve(repoRoot, "logs"));
60 const stateRootDir = resolve(getOptionalString(input.stateDir) ?? resolve(repoRoot, "state"));
61 const paths = resolveCodexdRuntimePaths(repoRoot, logsRootDir, stateRootDir);
62 const server = resolveServerConfig(input, repoRoot);
63
64 return {
65 nodeId: getOptionalString(input.nodeId) ?? DEFAULT_NODE_ID,
66 version: getOptionalString(input.version),
67 eventCacheSize: normalizePositiveInteger(input.eventCacheSize, DEFAULT_EVENT_CACHE_SIZE, "event cache"),
68 smokeLifetimeMs: normalizePositiveInteger(
69 input.smokeLifetimeMs,
70 DEFAULT_SMOKE_LIFETIME_MS,
71 "smoke lifetime"
72 ),
73 paths,
74 service: {
75 eventStreamPath:
76 getOptionalString(input.eventStreamPath) ?? DEFAULT_EVENT_STREAM_PATH,
77 localApiBase: getOptionalString(input.localApiBase) ?? DEFAULT_LOCAL_API_BASE
78 },
79 server
80 };
81}
82
83export function parseCodexdCliRequest(
84 argv: readonly string[],
85 env: CodexdEnvironment = {}
86): CodexdCliRequest {
87 const tokens = argv.slice(2);
88 let action: CodexdCliAction = "start";
89 let actionSet = false;
90 let printJson = false;
91 let runOnce = false;
92 let eventCacheSize = parseOptionalInteger(env.BAA_CODEXD_EVENT_CACHE_SIZE);
93 let eventStreamPath = env.BAA_CODEXD_EVENT_STREAM_PATH;
94 let localApiBase = env.BAA_CODEXD_LOCAL_API_BASE;
95 let logsDir = env.BAA_CODEXD_LOGS_DIR ?? env.BAA_LOGS_DIR;
96 let mode = env.BAA_CODEXD_MODE;
97 let nodeId = env.BAA_NODE_ID;
98 let repoRoot = env.BAA_CODEXD_REPO_ROOT;
99 let serverArgs: string[] | undefined = parseArgumentList(env.BAA_CODEXD_SERVER_ARGS);
100 let serverArgsSet = serverArgs !== undefined;
101 let serverCommand = env.BAA_CODEXD_SERVER_COMMAND;
102 let serverCwd = env.BAA_CODEXD_SERVER_CWD;
103 let serverEndpoint = env.BAA_CODEXD_SERVER_ENDPOINT;
104 let serverStrategy = env.BAA_CODEXD_SERVER_STRATEGY;
105 let smokeLifetimeMs = parseOptionalInteger(env.BAA_CODEXD_SMOKE_LIFETIME_MS);
106 let stateDir = env.BAA_CODEXD_STATE_DIR ?? env.BAA_STATE_DIR;
107 let version = env.BAA_CODEXD_VERSION ?? null;
108
109 for (let index = 0; index < tokens.length; index += 1) {
110 const token = tokens[index];
111
112 if (token == null) {
113 continue;
114 }
115
116 if (token === "--help" || token === "-h" || token === "help") {
117 return { action: "help" };
118 }
119
120 if (token === "--json") {
121 printJson = true;
122 continue;
123 }
124
125 if (token === "--run-once") {
126 runOnce = true;
127 continue;
128 }
129
130 if (token === "--repo-root") {
131 repoRoot = readCliValue(tokens, index, "--repo-root");
132 index += 1;
133 continue;
134 }
135
136 if (token === "--node-id") {
137 nodeId = readCliValue(tokens, index, "--node-id");
138 index += 1;
139 continue;
140 }
141
142 if (token === "--mode") {
143 mode = readCliValue(tokens, index, "--mode");
144 index += 1;
145 continue;
146 }
147
148 if (token === "--logs-dir") {
149 logsDir = readCliValue(tokens, index, "--logs-dir");
150 index += 1;
151 continue;
152 }
153
154 if (token === "--local-api-base") {
155 localApiBase = readCliValue(tokens, index, "--local-api-base");
156 index += 1;
157 continue;
158 }
159
160 if (token === "--event-stream-path") {
161 eventStreamPath = readCliValue(tokens, index, "--event-stream-path");
162 index += 1;
163 continue;
164 }
165
166 if (token === "--state-dir") {
167 stateDir = readCliValue(tokens, index, "--state-dir");
168 index += 1;
169 continue;
170 }
171
172 if (token === "--server-endpoint") {
173 serverEndpoint = readCliValue(tokens, index, "--server-endpoint");
174 index += 1;
175 continue;
176 }
177
178 if (token === "--server-strategy") {
179 serverStrategy = readCliValue(tokens, index, "--server-strategy");
180 index += 1;
181 continue;
182 }
183
184 if (token === "--server-command") {
185 serverCommand = readCliValue(tokens, index, "--server-command");
186 index += 1;
187 continue;
188 }
189
190 if (token === "--server-arg") {
191 const serverArg = readCliValue(tokens, index, "--server-arg");
192
193 if (!serverArgsSet || serverArgs == null) {
194 serverArgs = [];
195 serverArgsSet = true;
196 }
197
198 serverArgs.push(serverArg);
199 index += 1;
200 continue;
201 }
202
203 if (token === "--server-cwd") {
204 serverCwd = readCliValue(tokens, index, "--server-cwd");
205 index += 1;
206 continue;
207 }
208
209 if (token === "--event-cache-size") {
210 eventCacheSize = parseStrictInteger(readCliValue(tokens, index, "--event-cache-size"), "--event-cache-size");
211 index += 1;
212 continue;
213 }
214
215 if (token === "--smoke-lifetime-ms") {
216 smokeLifetimeMs = parseStrictInteger(
217 readCliValue(tokens, index, "--smoke-lifetime-ms"),
218 "--smoke-lifetime-ms"
219 );
220 index += 1;
221 continue;
222 }
223
224 if (token === "--version") {
225 version = readCliValue(tokens, index, "--version");
226 index += 1;
227 continue;
228 }
229
230 if (token.startsWith("--")) {
231 throw new Error(`Unknown codexd flag "${token}".`);
232 }
233
234 if (actionSet) {
235 throw new Error(`Unexpected extra codexd argument "${token}".`);
236 }
237
238 if (!isCodexdCliAction(token)) {
239 throw new Error(`Unknown codexd action "${token}".`);
240 }
241
242 action = token;
243 actionSet = true;
244 }
245
246 const config = resolveCodexdConfig({
247 eventCacheSize,
248 eventStreamPath,
249 localApiBase,
250 logsDir,
251 mode,
252 nodeId,
253 repoRoot,
254 serverArgs,
255 serverCommand,
256 serverCwd,
257 serverEndpoint,
258 serverStrategy,
259 smokeLifetimeMs,
260 stateDir,
261 version
262 });
263
264 if (action === "start") {
265 return {
266 action,
267 config,
268 printJson,
269 runOnce,
270 lifetimeMs: config.smokeLifetimeMs
271 };
272 }
273
274 return {
275 action,
276 config,
277 printJson
278 };
279}
280
281export function formatCodexdConfigText(config: CodexdResolvedConfig): string {
282 return [
283 `node_id: ${config.nodeId}`,
284 `version: ${config.version ?? "not-set"}`,
285 `mode: ${config.server.mode}`,
286 `endpoint: ${config.server.endpoint}`,
287 `child_strategy: ${config.server.childStrategy}`,
288 `child_command: ${config.server.childCommand}`,
289 `child_args: ${config.server.childArgs.join(" ") || "(none)"}`,
290 `child_cwd: ${config.server.childCwd}`,
291 `local_api_base: ${config.service.localApiBase}`,
292 `event_stream_path: ${config.service.eventStreamPath}`,
293 `logs_dir: ${config.paths.logsDir}`,
294 `state_dir: ${config.paths.stateDir}`,
295 `event_cache_size: ${config.eventCacheSize}`,
296 `smoke_lifetime_ms: ${config.smokeLifetimeMs}`
297 ].join("\n");
298}
299
300export function getCodexdUsageText(): string {
301 return [
302 "Usage:",
303 " node apps/codexd/dist/index.js [start] [options]",
304 " node apps/codexd/dist/index.js status [--json]",
305 " node apps/codexd/dist/index.js config [--json]",
306 " node apps/codexd/dist/index.js smoke [--json]",
307 " node apps/codexd/dist/index.js help",
308 "",
309 "Options:",
310 " --repo-root <path>",
311 " --node-id <id>",
312 " --mode <app-server|exec>",
313 " --logs-dir <path>",
314 " --local-api-base <http://127.0.0.1:4319>",
315 " --event-stream-path <path>",
316 " --state-dir <path>",
317 " --server-endpoint <url-or-stdio>",
318 " --server-strategy <spawn|external>",
319 " --server-command <command>",
320 " --server-arg <arg>",
321 " --server-cwd <path>",
322 " --event-cache-size <integer>",
323 " --smoke-lifetime-ms <integer>",
324 " --version <string>",
325 " --run-once",
326 " --json",
327 " --help",
328 "",
329 "Environment:",
330 " BAA_NODE_ID",
331 " BAA_LOGS_DIR",
332 " BAA_STATE_DIR",
333 " BAA_CODEXD_REPO_ROOT",
334 " BAA_CODEXD_MODE",
335 " BAA_CODEXD_LOGS_DIR",
336 " BAA_CODEXD_STATE_DIR",
337 " BAA_CODEXD_LOCAL_API_BASE",
338 " BAA_CODEXD_EVENT_STREAM_PATH",
339 " BAA_CODEXD_SERVER_ENDPOINT",
340 " BAA_CODEXD_SERVER_STRATEGY",
341 " BAA_CODEXD_SERVER_COMMAND",
342 " BAA_CODEXD_SERVER_ARGS",
343 " BAA_CODEXD_SERVER_CWD",
344 " BAA_CODEXD_EVENT_CACHE_SIZE",
345 " BAA_CODEXD_SMOKE_LIFETIME_MS",
346 " BAA_CODEXD_VERSION",
347 "",
348 "Notes:",
349 " start manages one configured Codex child process or an external endpoint placeholder.",
350 " smoke always uses an embedded stub child so the scaffold can be verified without Codex CLI."
351 ].join("\n");
352}
353
354function getDefaultRepoRoot(): string {
355 if (typeof process !== "undefined" && typeof process.cwd === "function") {
356 return process.cwd();
357 }
358
359 return ".";
360}
361
362function resolveServerConfig(input: CodexdConfigInput, repoRoot: string): CodexdServerConfig {
363 const mode = normalizeServerMode(input.mode);
364 const childStrategy = normalizeChildStrategy(input.serverStrategy);
365 const childArgs = input.serverArgs == null ? [...DEFAULT_SERVER_ARGS] : [...input.serverArgs];
366
367 return {
368 mode,
369 endpoint: getOptionalString(input.serverEndpoint) ?? DEFAULT_SERVER_ENDPOINT,
370 childStrategy,
371 childCommand: getOptionalString(input.serverCommand) ?? DEFAULT_SERVER_COMMAND,
372 childArgs,
373 childCwd: resolve(getOptionalString(input.serverCwd) ?? repoRoot)
374 };
375}
376
377function resolveCodexdRuntimePaths(
378 repoRoot: string,
379 logsRootDir: string,
380 stateRootDir: string
381): CodexdRuntimePaths {
382 const logsDir = resolve(logsRootDir, "codexd");
383 const stateDir = resolve(stateRootDir, "codexd");
384
385 return {
386 repoRoot,
387 logsRootDir,
388 stateRootDir,
389 logsDir,
390 stateDir,
391 structuredEventLogPath: resolve(logsDir, "events.jsonl"),
392 stdoutLogPath: resolve(logsDir, "stdout.log"),
393 stderrLogPath: resolve(logsDir, "stderr.log"),
394 identityPath: resolve(stateDir, "identity.json"),
395 daemonStatePath: resolve(stateDir, "daemon-state.json"),
396 sessionRegistryPath: resolve(stateDir, "session-registry.json"),
397 runRegistryPath: resolve(stateDir, "run-registry.json"),
398 recentEventsPath: resolve(stateDir, "recent-events.json")
399 };
400}
401
402function isCodexdCliAction(value: string): value is Exclude<CodexdCliAction, "help"> {
403 return value === "config" || value === "smoke" || value === "start" || value === "status";
404}
405
406function normalizeChildStrategy(value: string | undefined): CodexdServerConfig["childStrategy"] {
407 switch (value ?? DEFAULT_SERVER_STRATEGY) {
408 case "external":
409 return "external";
410 case "spawn":
411 return "spawn";
412 default:
413 throw new Error(`Unsupported codexd child strategy "${value}".`);
414 }
415}
416
417function normalizeServerMode(value: string | undefined): CodexdServerConfig["mode"] {
418 switch (value ?? DEFAULT_SERVER_MODE) {
419 case "app-server":
420 return "app-server";
421 case "exec":
422 return "exec";
423 default:
424 throw new Error(`Unsupported codexd mode "${value}".`);
425 }
426}
427
428function parseArgumentList(value: string | undefined): string[] | undefined {
429 if (value == null) {
430 return undefined;
431 }
432
433 const trimmed = value.trim();
434
435 if (trimmed === "") {
436 return [];
437 }
438
439 if (trimmed.startsWith("[")) {
440 const parsed = JSON.parse(trimmed) as unknown;
441
442 if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
443 throw new Error("BAA_CODEXD_SERVER_ARGS must be a JSON string array when JSON syntax is used.");
444 }
445
446 return [...parsed];
447 }
448
449 return trimmed.split(/\s+/u);
450}
451
452function parseOptionalInteger(value: string | undefined): number | undefined {
453 if (value == null || value.trim() === "") {
454 return undefined;
455 }
456
457 return parseStrictInteger(value, "integer value");
458}
459
460function parseStrictInteger(value: string, label: string): number {
461 const parsed = Number(value);
462
463 if (!Number.isInteger(parsed)) {
464 throw new Error(`Invalid ${label} "${value}".`);
465 }
466
467 return parsed;
468}
469
470function normalizePositiveInteger(value: number | undefined, fallback: number, label: string): number {
471 const candidate = value ?? fallback;
472
473 if (!Number.isInteger(candidate) || candidate < 0) {
474 throw new Error(`Invalid ${label} value "${String(value)}".`);
475 }
476
477 return candidate;
478}
479
480function getOptionalString(value: string | null | undefined): string | null {
481 if (value == null) {
482 return null;
483 }
484
485 const trimmed = value.trim();
486 return trimmed === "" ? null : trimmed;
487}
488
489function readCliValue(tokens: readonly string[], index: number, flag: string): string {
490 const value = tokens[index + 1];
491
492 if (value == null || value.startsWith("--")) {
493 throw new Error(`Missing value for ${flag}.`);
494 }
495
496 return value;
497}