1#!/usr/bin/env node
2// @ts-check
3
4import { spawn } from "node:child_process";
5import { accessSync, existsSync, constants as fsConstants } from "node:fs";
6import * as NodeModule from "node:module";
7import { constants } from "node:os";
8import { pathToFileURL } from "node:url";
9import path from "pathe";
10
11NodeModule.enableCompileCache?.();
12
13const binDir = path.dirname(import.meta.filename);
14const entry = path.join(binDir, "alchemy.js");
15const isDev = !(
16 binDir.includes("/node_modules/") || binDir.includes("\\node_modules\\")
17);
18
19const execpath = (process.env.npm_execpath ?? "").toLowerCase();
20const userAgent = (process.env.npm_config_user_agent ?? "").toLowerCase();
21
22const runningInBun =
23 // @ts-ignore
24 "Bun" in globalThis && typeof globalThis.Bun !== "undefined";
25
26const runtime =
27 runningInBun || execpath.includes("bun") || userAgent.startsWith("bun/")
28 ? "bun"
29 : "node";
30
31if (runtime === "node") {
32 // Oxc's loader requires module.registerHooks. Keep this gate in sync with
33 // src/Util/Node.ts; the launcher must run before TypeScript can be loaded.
34 const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
35 const supportsHooks =
36 (major === 22 && minor >= 15) ||
37 (major === 23 && minor >= 5) ||
38 major >= 24;
39 if (!supportsHooks) {
40 process.stderr.write(
41 `alchemy: node ${process.versions.node} is not supported ` +
42 "(module.registerHooks needs node 22.15+, 23.5+, or 24+).\n" +
43 "Use a newer node, or run alchemy with bun.\n",
44 );
45 process.exit(1);
46 }
47
48 process.env.NODE_ENV = "production";
49 const loader = isDev ? "register-dev-mode.js" : "register-oxc.js";
50 await import(new URL(loader, import.meta.url).href);
51 await import(pathToFileURL(entry).href);
52} else {
53 // Bun always loads source. Start a child so its JSX settings are established
54 // before loading the CLI, independent of the caller's tsconfig.
55 const tsconfig = path.join(binDir, isDev ? ".." : ".", "tsconfig.json");
56 const bun = runningInBun ? process.execPath : (findBun() ?? "bun");
57 const args = [
58 `--tsconfig-override=${tsconfig}`,
59 entry,
60 ...process.argv.slice(2),
61 ];
62
63 process.env.NODE_ENV = "production";
64 // Bun's tsconfig override can emit this benign diagnostic (oven-sh/bun#25730).
65 // Keep the parent to filter it and forward signals, IPC and exit status.
66 foregroundChild(
67 bun,
68 args,
69 (line) => !line.includes("directory mismatch for directory"),
70 );
71}
72
73/**
74 * Run the CLI as a foreground child while retaining the launcher's filtered
75 * stderr and IPC forwarding. Effect's child-process service intentionally
76 * doesn't expose Node's IPC channel, so this small launcher keeps that boundary
77 * on the native Node API.
78 *
79 * @param {string} program
80 * @param {ReadonlyArray<string>} args
81 * @param {(line: string) => boolean} stderrFilter
82 */
83function foregroundChild(program, args, stderrFilter) {
84 /** @type {import("node:child_process").StdioOptions} */
85 const stdio = process.send ? [0, 1, "pipe", "ipc"] : [0, 1, "pipe"];
86 const child = spawn(program, args, { stdio });
87 /** @type {Map<NodeJS.Signals, () => void>} */
88 const listeners = new Map();
89
90 for (const signal of /** @type {Array<NodeJS.Signals>} */ (
91 Object.keys(constants.signals)
92 )) {
93 if (signal === "SIGKILL" || signal === "SIGSTOP") continue;
94 const forward = () => child.kill(signal);
95 try {
96 process.on(signal, forward);
97 listeners.set(signal, forward);
98 } catch {}
99 }
100
101 let buffer = "";
102 child.stderr?.on("data", (chunk) => {
103 buffer += chunk.toString();
104 const lines = buffer.split(/(?<=\n)/);
105 // The lookbehind split KEEPS separators, so a chunk ending in "\n"
106 // yields a COMPLETE final element — unconditionally popping it held
107 // the last line of every stderr burst (e.g. an error trace's final
108 // frame) until the next write or stream end, where it surfaced after
109 // Ctrl+C looking like unrelated output. Only buffer a genuine partial.
110 buffer =
111 lines.length > 0 && !lines[lines.length - 1].endsWith("\n")
112 ? (lines.pop() ?? "")
113 : "";
114 for (const line of lines) {
115 if (stderrFilter(line)) process.stderr.write(line);
116 }
117 });
118 child.stderr?.on("end", () => {
119 if (buffer && stderrFilter(buffer)) process.stderr.write(buffer);
120 });
121
122 if (process.send) {
123 child.on("message", (message, handle) =>
124 process.send?.(
125 /** @type {import("node:child_process").Serializable} */ (message),
126 handle,
127 ),
128 );
129 process.on("message", (message, handle) =>
130 child.send(
131 /** @type {import("node:child_process").Serializable} */ (message),
132 handle,
133 ),
134 );
135 }
136
137 child.on("close", (code, signal) => {
138 for (const [name, listener] of listeners) {
139 process.removeListener(name, listener);
140 }
141 if (signal) {
142 process.kill(process.pid, signal);
143 } else {
144 process.exit(code ?? 0);
145 }
146 });
147}
148
149/**
150 * The bun executable, as an absolute path, or `undefined` when it cannot be
151 * found. `bun run` names itself in `npm_execpath`; otherwise walk `PATH`.
152 *
153 * @returns {string | undefined}
154 */
155function findBun() {
156 const execpath = process.env.npm_execpath;
157 if (execpath && path.basename(execpath).startsWith("bun")) {
158 return existsSync(execpath) ? execpath : undefined;
159 }
160 for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
161 if (directory === "") continue;
162 const candidate = path.join(directory, "bun");
163 try {
164 accessSync(candidate, fsConstants.X_OK);
165 return candidate;
166 } catch {}
167 }
168 return undefined;
169}