1// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import * as HostProcess from "@t3tools/shared/HostProcess";
6
7export interface FakeCliOptions {
8 /** Directory the launcher and its stub are written into. */
9 readonly directory: string;
10 /** Command name as callers spawn it, e.g. `codex`. */
11 readonly name: string;
12 /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */
13 readonly source: string;
14 /** Environment set for the stub before it runs, on top of the inherited environment. */
15 readonly env?: Readonly<Record<string, string>>;
16 /**
17 * Platform the launcher is shaped for. Effect callers should pass the value
18 * they read from `HostProcess.Platform` so an injected override is honoured;
19 * defaults to the real host for plain tests.
20 */
21 readonly platform?: NodeJS.Platform;
22}
23
24/**
25 * Writes a fake CLI whose behaviour lives in a Node stub. On posix the
26 * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a
27 * shebang file is not executable there and `resolveSpawnCommand` routes
28 * `.cmd` through a shell. Returns the launcher path to hand to the code under
29 * test, which on Windows carries the `.cmd` extension.
30 */
31export function writeFakeCli(options: FakeCliOptions): string {
32 NodeFS.mkdirSync(options.directory, { recursive: true });
33 const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`);
34 // The environment rides in a JSON sidecar the stub applies to itself, since
35 // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely.
36 const envPath = NodePath.join(options.directory, `${options.name}-env.json`);
37 NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8");
38 NodeFS.writeFileSync(
39 stubPath,
40 [
41 'import { readFileSync as readFakeCliEnv } from "node:fs";',
42 `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`,
43 options.source,
44 ].join("\n"),
45 "utf8",
46 );
47
48 if ((options.platform ?? HostProcess.Platform.defaultValue()) === "win32") {
49 const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`);
50 NodeFS.writeFileSync(
51 launcherPath,
52 ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join(
53 "\r\n",
54 ),
55 "utf8",
56 );
57 return launcherPath;
58 }
59
60 const launcherPath = NodePath.join(options.directory, options.name);
61 NodeFS.writeFileSync(
62 launcherPath,
63 ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"),
64 "utf8",
65 );
66 NodeFS.chmodSync(launcherPath, 0o755);
67 return launcherPath;
68}
69
70/**
71 * Stub source that becomes `scriptPath`: after optionally requiring a leading
72 * argv prefix, it imports the script into its own process so the stub is the
73 * agent. Nothing sits between the code under test and the mock, so a kill or
74 * a closed stdin reaches it directly and its exit log is faithful on every
75 * host. The script must not read `process.argv`; the mock agents are driven
76 * by environment and stdin.
77 */
78export function execScriptSource(options: {
79 readonly scriptPath: string;
80 readonly expectedArgs?: ReadonlyArray<string>;
81 /** Tab-separated argv appended here per invocation, for launch-flag assertions. */
82 readonly argvLogPath?: string;
83 /** Wait before handing over, for tests that race a slow startup. */
84 readonly delayMs?: number;
85}): string {
86 return [
87 'import { appendFileSync } from "node:fs";',
88 'import { pathToFileURL } from "node:url";',
89 "const args = process.argv.slice(2);",
90 ...(options.argvLogPath === undefined
91 ? []
92 : [
93 `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`,
94 ]),
95 ...(options.delayMs === undefined
96 ? []
97 : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]),
98 `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`,
99 "if (expected.some((value, index) => args[index] !== value)) {",
100 ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);',
101 " process.exit(11);",
102 "}",
103 `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`,
104 "",
105 ].join("\n");
106}