1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import * as HostProcess from "./HostProcess.ts";
8import { CommandResolutionCache, resolveCommandPath } from "./shell.ts";
9
10const NodeRuntimeFeature = Schema.Literals([
11 "Local device support",
12 "Device automation",
13 "Antigravity",
14 "Antigravity sign-in",
15]);
16
17export const nodeRuntimeUnavailableMessage = (feature: typeof NodeRuntimeFeature.Type): string =>
18 `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`;
19
20export class NodeRuntimeUnavailableError extends Schema.TaggedError<NodeRuntimeUnavailableError>()(
21 "NodeRuntimeUnavailableError",
22 { feature: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) },
23) {
24 override get message(): string {
25 return nodeRuntimeUnavailableMessage(this.feature);
26 }
27}
28
29export interface SelfInvocation {
30 /** The binary to spawn: Node, Electron (with `ELECTRON_RUN_AS_NODE`), or the packaged T3. */
31 readonly command: string;
32 /**
33 * The absolute entrypoint script to place before the subcommand, or
34 * undefined for the packaged binary, which dispatches subcommands from
35 * argv[2] and treats a leading path as the subcommand itself.
36 */
37 readonly entrypoint: string | undefined;
38}
39
40/**
41 * How another process runs this T3 install's CLI, for hidden subcommands the
42 * server hands to children such as `acp-mcp-bridge`. `process.execPath` plus
43 * `argv[1]` only works for a script run by Node; the single-executable has no
44 * entrypoint script (Node repeats the binary at argv[1]), so callers must not
45 * assemble the pair themselves.
46 */
47export const resolveSelfInvocation = Effect.fn("nodeRuntime.resolveSelfInvocation")(function* () {
48 const command = yield* HostProcess.ExecutablePath;
49 if (yield* HostProcess.IsExecutable)
50 return { command, entrypoint: undefined } satisfies SelfInvocation;
51 const path = yield* Path.Path;
52 const entry = (yield* HostProcess.Arguments)[1];
53 // Children spawn from their own working directory, so the script path must be absolute.
54 return {
55 command,
56 entrypoint: entry === undefined ? undefined : path.resolve(entry),
57 } satisfies SelfInvocation;
58});
59
60/** `[entrypoint?, ...args]`: the argv that runs `args` against this T3 install. */
61export const selfInvocationArgs = (
62 invocation: SelfInvocation,
63 args: ReadonlyArray<string>,
64): ReadonlyArray<string> =>
65 invocation.entrypoint === undefined ? args : [invocation.entrypoint, ...args];
66
67/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */
68export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* (
69 feature: typeof NodeRuntimeFeature.Type,
70 environment?: NodeJS.ProcessEnv,
71) {
72 const executablePath = yield* HostProcess.ExecutablePath;
73 if (!(yield* HostProcess.IsExecutable)) return executablePath;
74
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const platform = yield* HostProcess.Platform;
78 const env = environment ?? (yield* HostProcess.Environment);
79 const resolvedNodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", {
80 // Batch wrappers require a shell; helper callers launch the runtime directly.
81 env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env,
82 }).pipe(
83 // Refresh immediately after the user installs Node and retries setup.
84 Effect.provideService(CommandResolutionCache, new Map()),
85 );
86 if (resolvedNodePath === null) return yield* new NodeRuntimeUnavailableError({ feature });
87 const nodePath = path.resolve(resolvedNodePath);
88 // A launcher or symlink named node must not point back at the standalone app.
89 const resolvedPath = yield* fs
90 .realPath(nodePath)
91 .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })));
92 if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature });
93 const [hostInfo, nodeInfo] = yield* Effect.all([
94 fs.stat(executablePath).pipe(Effect.option),
95 fs.stat(nodePath).pipe(Effect.option),
96 ]);
97 if (
98 Option.isSome(hostInfo) &&
99 Option.isSome(nodeInfo) &&
100 hostInfo.value.dev === nodeInfo.value.dev &&
101 Option.isSome(hostInfo.value.ino) &&
102 Option.isSome(nodeInfo.value.ino) &&
103 Number.isSafeInteger(hostInfo.value.ino.value) &&
104 hostInfo.value.ino.value > 0 &&
105 hostInfo.value.ino.value === nodeInfo.value.ino.value
106 ) {
107 return yield* new NodeRuntimeUnavailableError({ feature });
108 }
109 // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact.
110 return nodePath;
111});