@hyperdrive.bot/fleet-server 0.3.155 → 0.3.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/server/server/bootstrap.js +8 -0
  2. package/dist/server/server/ingestion/apply.js +12 -0
  3. package/dist/server/server/ingestion/dry-run.d.ts +3 -1
  4. package/dist/server/server/ingestion/dry-run.js +8 -0
  5. package/dist/server/server/ingestion/subscriptions/nudge-route.d.ts +42 -0
  6. package/dist/server/server/ingestion/subscriptions/nudge-route.js +67 -0
  7. package/dist/server/server/ingestion/subscriptions/poller.d.ts +11 -0
  8. package/dist/server/server/ingestion/subscriptions/poller.js +23 -3
  9. package/dist/server/server/session/chat/chat-ingestion-session.js +5 -1
  10. package/dist/server/utils/git-command-cache.d.ts +67 -0
  11. package/dist/server/utils/git-command-cache.js +436 -0
  12. package/dist/server/utils/run-git-command.js +14 -4
  13. package/dist/server/utils/spawn-broker-child.mjs +156 -0
  14. package/dist/server/utils/spawn-broker.d.ts +120 -0
  15. package/dist/server/utils/spawn-broker.js +299 -0
  16. package/dist/server/utils/spawn.d.ts +21 -0
  17. package/dist/server/utils/spawn.js +144 -10
  18. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js → index-461552a3716a48e99f87634c93c7b220.js} +6 -6
  19. package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.br +0 -0
  20. package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.gz +0 -0
  21. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.br → index-461552a3716a48e99f87634c93c7b220.js.map.br} +0 -0
  22. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.gz → index-461552a3716a48e99f87634c93c7b220.js.map.gz} +0 -0
  23. package/dist/server/web-ui/index.html +1 -1
  24. package/dist/server/web-ui/index.html.br +0 -0
  25. package/dist/server/web-ui/index.html.gz +0 -0
  26. package/dist/src/utils/spawn-broker-child.mjs +156 -0
  27. package/dist/src/utils/spawn-broker.js +299 -0
  28. package/dist/src/utils/spawn.js +144 -10
  29. package/package.json +8 -8
  30. package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.br +0 -0
  31. package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.gz +0 -0
@@ -191,6 +191,6 @@
191
191
  <body>
192
192
  <noscript>You need to enable JavaScript to run this app.</noscript>
193
193
  <div id="root"></div>
194
- <script src="/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js" defer></script>
194
+ <script src="/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js" defer></script>
195
195
  </body>
196
196
  </html>
Binary file
Binary file
@@ -0,0 +1,156 @@
1
+ // Spawn broker helper process. The daemon forks this small Node process once and
2
+ // asks it to run non-interactive subprocesses (git, gh, which, ...) over IPC, so
3
+ // the daemon itself never forks its multi-GB image: on WSL2 every direct spawn()
4
+ // from the daemon blocked its event loop for about 100ms.
5
+ //
6
+ // Protocol (JSON over the IPC channel):
7
+ // parent -> child { type: "spawn", id, command, args, cwd, env, shell }
8
+ // { type: "kill", id, signal }
9
+ // child -> parent { type: "spawned", id, pid }
10
+ // { type: "data", id, fd: 1 | 2, data: base64 }
11
+ // { type: "error", id, error: { message, code, errno, syscall, path } }
12
+ // { type: "close", id, exitCode, signal }
13
+ //
14
+ // Timeouts, output caps and exit-code policy stay in the parent, which kills the
15
+ // child through the "kill" message exactly like it would kill a direct ChildProcess.
16
+ import { spawn } from "node:child_process";
17
+
18
+ const children = new Map();
19
+
20
+ function send(message) {
21
+ // Returns false when the channel is backed up (or gone; the disconnect handler
22
+ // cleans up in that case).
23
+ return sendRaw(message);
24
+ }
25
+
26
+ function serializeError(error) {
27
+ return {
28
+ message: error?.message ?? String(error),
29
+ code: error?.code,
30
+ errno: error?.errno,
31
+ syscall: error?.syscall,
32
+ path: error?.path,
33
+ };
34
+ }
35
+
36
+ const CHUNK_BYTES = 64 * 1024;
37
+
38
+ // Forward a stream in bounded chunks, stop forwarding once the caller's cap is
39
+ // reached (the caller decides whether to kill), and pause the source while the
40
+ // IPC channel is backed up so output never piles up in memory on either side.
41
+ function forward(stream, id, fd, maxBytes) {
42
+ const cap = Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : Infinity;
43
+ let forwarded = 0;
44
+ stream.on("data", (chunk) => {
45
+ if (forwarded >= cap) return;
46
+ const slice = chunk.length > cap - forwarded ? chunk.subarray(0, cap - forwarded) : chunk;
47
+ forwarded += slice.length;
48
+ for (let offset = 0; offset < slice.length; offset += CHUNK_BYTES) {
49
+ const part = slice.subarray(offset, offset + CHUNK_BYTES);
50
+ const ok = send({ type: "data", id, fd, data: part.toString("base64") });
51
+ if (!ok) {
52
+ stream.pause();
53
+ waitForDrain(() => stream.resume());
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ const drainWaiters = [];
60
+ function waitForDrain(callback) {
61
+ drainWaiters.push(callback);
62
+ if (drainWaiters.length > 1) return;
63
+ // An empty message's send callback fires once everything queued before it
64
+ // has been written to the channel.
65
+ sendRaw({ type: "noop" }, () => {
66
+ const waiters = drainWaiters.splice(0);
67
+ for (const waiter of waiters) waiter();
68
+ });
69
+ }
70
+
71
+ function sendRaw(message, callback) {
72
+ if (!process.connected) return false;
73
+ try {
74
+ return process.send(message, undefined, undefined, callback);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function handleSpawn(message) {
81
+ const { id } = message;
82
+ let child;
83
+ try {
84
+ child = spawn(message.command, message.args, {
85
+ cwd: message.cwd,
86
+ env: message.env,
87
+ shell: message.shell ?? false,
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ windowsHide: true,
90
+ });
91
+ } catch (error) {
92
+ send({ type: "error", id, error: serializeError(error) });
93
+ return;
94
+ }
95
+ children.set(id, child);
96
+ if (child.pid !== undefined) {
97
+ send({ type: "spawned", id, pid: child.pid });
98
+ }
99
+ forward(child.stdout, id, 1, message.maxStdoutBytes);
100
+ forward(child.stderr, id, 2, message.maxStderrBytes);
101
+ child.on("error", (error) => {
102
+ children.delete(id);
103
+ send({ type: "error", id, error: serializeError(error) });
104
+ });
105
+ child.on("close", (exitCode, signal) => {
106
+ children.delete(id);
107
+ send({ type: "close", id, exitCode, signal });
108
+ });
109
+ }
110
+
111
+ function killAll() {
112
+ for (const child of children.values()) {
113
+ try {
114
+ child.kill("SIGKILL");
115
+ } catch {
116
+ // already gone
117
+ }
118
+ }
119
+ children.clear();
120
+ }
121
+
122
+ process.on("message", (message) => {
123
+ if (!message || typeof message !== "object") return;
124
+ if (message.type === "spawn") {
125
+ handleSpawn(message);
126
+ return;
127
+ }
128
+ if (message.type === "kill") {
129
+ const child = children.get(message.id);
130
+ if (child) {
131
+ try {
132
+ child.kill(message.signal ?? "SIGTERM");
133
+ } catch {
134
+ // already gone
135
+ }
136
+ }
137
+ return;
138
+ }
139
+ if (message.type === "crash-for-test" && process.env.PASEO_SPAWN_BROKER_TEST_HOOKS === "1") {
140
+ process.exit(1);
141
+ }
142
+ });
143
+
144
+ // The daemon owns us: when its IPC channel closes (clean shutdown or crash) we take
145
+ // our children down and exit, so nothing is orphaned.
146
+ process.on("disconnect", () => {
147
+ killAll();
148
+ process.exit(0);
149
+ });
150
+
151
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
152
+ process.on(signal, () => {
153
+ killAll();
154
+ process.exit(0);
155
+ });
156
+ }
@@ -0,0 +1,299 @@
1
+ import { fork } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { existsSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ const FAILURE_COOLDOWN_MS = 60000;
6
+ const MAX_CONSECUTIVE_FAILURES = 3;
7
+ export function isSpawnBrokerEnabled() {
8
+ if (process.platform === "win32")
9
+ return false;
10
+ const flag = process.env.PASEO_SPAWN_BROKER;
11
+ return flag !== "0" && flag !== "false";
12
+ }
13
+ function resolveBrokerChildPath() {
14
+ // The helper is a plain .mjs file that sits next to this module both in src/ (tests,
15
+ // tsx dev) and in dist/server/utils/ (build:lib copies it), so the published package
16
+ // ships it at node_modules/@hyperdrive.bot/fleet-server/dist/server/utils/.
17
+ return fileURLToPath(new URL("./spawn-broker-child.mjs", import.meta.url));
18
+ }
19
+ /**
20
+ * A process run by the broker. Emits the same events, in the same order, as a
21
+ * direct ChildProcess with stdio ["ignore", "pipe", "pipe"]. If the broker fails
22
+ * before the process is confirmed started, it transparently falls back to a
23
+ * direct spawn so the caller never sees the difference.
24
+ */
25
+ export class BrokeredChildProcess extends EventEmitter {
26
+ constructor(id, broker, directSpawn, policy) {
27
+ super();
28
+ this.stdout = new EventEmitter();
29
+ this.stderr = new EventEmitter();
30
+ this.killed = false;
31
+ this.brokered = true;
32
+ this.started = false;
33
+ this.closed = false;
34
+ this.fallbackChild = null;
35
+ this.pendingKill = null;
36
+ this.id = id;
37
+ this.broker = broker;
38
+ this.directSpawn = directSpawn;
39
+ this.retryOnDirect = policy.retryOnDirect;
40
+ }
41
+ kill(signal = "SIGTERM") {
42
+ if (this.closed)
43
+ return false;
44
+ this.killed = true;
45
+ if (this.fallbackChild) {
46
+ return this.fallbackChild.kill(signal);
47
+ }
48
+ this.pendingKill = signal;
49
+ this.broker.sendKill(this.id, signal);
50
+ return true;
51
+ }
52
+ /** @internal */
53
+ handleMessage(message) {
54
+ if (this.closed || this.fallbackChild)
55
+ return;
56
+ switch (message.type) {
57
+ case "spawned":
58
+ this.started = true;
59
+ this.pid = message.pid;
60
+ this.emit("spawn");
61
+ return;
62
+ case "data": {
63
+ const target = message.fd === 1 ? this.stdout : this.stderr;
64
+ target.emit("data", Buffer.from(message.data, "base64"));
65
+ return;
66
+ }
67
+ case "error": {
68
+ const error = Object.assign(new Error(message.error.message), {
69
+ code: message.error.code,
70
+ errno: message.error.errno,
71
+ syscall: message.error.syscall,
72
+ path: message.error.path,
73
+ });
74
+ this.emit("error", error);
75
+ return;
76
+ }
77
+ case "close":
78
+ this.closed = true;
79
+ this.broker.release(this.id);
80
+ this.emit("exit", message.exitCode, message.signal);
81
+ this.emit("close", message.exitCode, message.signal);
82
+ return;
83
+ }
84
+ }
85
+ /** @internal Broker died or could not be reached. */
86
+ handleBrokerLost() {
87
+ if (this.closed || this.fallbackChild)
88
+ return;
89
+ if (this.started) {
90
+ // The process really ran (the broker kills its children when it dies), so
91
+ // re-running it could repeat a side effect. Report it as killed instead.
92
+ this.closed = true;
93
+ this.emit("exit", null, "SIGKILL");
94
+ this.emit("close", null, "SIGKILL");
95
+ return;
96
+ }
97
+ if (this.retryOnDirect) {
98
+ this.startFallback();
99
+ return;
100
+ }
101
+ // The request may already have run (and been killed) inside the helper, so a
102
+ // mutating command must not run a second time: surface the loss instead.
103
+ this.closed = true;
104
+ const error = Object.assign(new Error("Spawn broker exited before confirming the process started"), { code: "ERR_SPAWN_BROKER_LOST" });
105
+ queueMicrotask(() => this.emit("error", error));
106
+ }
107
+ /** @internal */
108
+ startFallback() {
109
+ this.brokered = false;
110
+ let child;
111
+ try {
112
+ child = this.directSpawn();
113
+ }
114
+ catch (error) {
115
+ this.closed = true;
116
+ queueMicrotask(() => this.emit("error", error));
117
+ return;
118
+ }
119
+ this.fallbackChild = child;
120
+ this.pid = child.pid;
121
+ child.stdout?.on("data", (chunk) => this.stdout.emit("data", chunk));
122
+ child.stderr?.on("data", (chunk) => this.stderr.emit("data", chunk));
123
+ child.on("error", (error) => this.emit("error", error));
124
+ child.on("exit", (code, signal) => this.emit("exit", code, signal));
125
+ child.on("close", (code, signal) => {
126
+ this.closed = true;
127
+ this.emit("close", code, signal);
128
+ });
129
+ if (this.pendingKill !== null) {
130
+ child.kill(this.pendingKill);
131
+ }
132
+ }
133
+ }
134
+ export class SpawnBroker {
135
+ constructor(childPath = resolveBrokerChildPath()) {
136
+ this.child = null;
137
+ this.pending = new Map();
138
+ this.nextId = 1;
139
+ this.consecutiveFailures = 0;
140
+ this.disabledUntil = 0;
141
+ this.exitHookInstalled = false;
142
+ this.childPath = childPath;
143
+ }
144
+ get pendingCount() {
145
+ return this.pending.size;
146
+ }
147
+ get brokerPid() {
148
+ return this.child?.pid;
149
+ }
150
+ spawn(request, directSpawn, policy = { retryOnDirect: false }) {
151
+ const proc = new BrokeredChildProcess(this.nextId++, this, directSpawn, policy);
152
+ const child = this.ensureChild();
153
+ if (!child) {
154
+ // The request never left this process, so a direct spawn cannot double-run it.
155
+ proc.startFallback();
156
+ return proc;
157
+ }
158
+ this.pending.set(proc.id, proc);
159
+ this.updateRef();
160
+ try {
161
+ child.send({ type: "spawn", id: proc.id, ...request }, (error) => {
162
+ if (error)
163
+ this.handleChildLost(child);
164
+ });
165
+ }
166
+ catch {
167
+ this.handleChildLost(child);
168
+ }
169
+ return proc;
170
+ }
171
+ /** @internal */
172
+ sendKill(id, signal) {
173
+ const child = this.child;
174
+ if (!child || !child.connected)
175
+ return;
176
+ try {
177
+ child.send({ type: "kill", id, signal });
178
+ }
179
+ catch {
180
+ // handled by the disconnect path
181
+ }
182
+ }
183
+ /** @internal */
184
+ release(id) {
185
+ this.pending.delete(id);
186
+ this.updateRef();
187
+ }
188
+ /** Stop the helper. Pending requests fall back or report SIGKILL. */
189
+ dispose() {
190
+ const child = this.child;
191
+ if (child) {
192
+ this.handleChildLost(child);
193
+ child.kill("SIGTERM");
194
+ }
195
+ }
196
+ /** Test hook: make the helper exit abruptly. */
197
+ crashForTest() {
198
+ this.child?.send({ type: "crash-for-test" });
199
+ }
200
+ ensureChild() {
201
+ if (this.child)
202
+ return this.child;
203
+ if (Date.now() < this.disabledUntil)
204
+ return null;
205
+ if (!existsSync(this.childPath)) {
206
+ this.recordFailure();
207
+ return null;
208
+ }
209
+ let child;
210
+ try {
211
+ child = fork(this.childPath, [], {
212
+ execArgv: [],
213
+ stdio: ["ignore", "ignore", "inherit", "ipc"],
214
+ serialization: "json",
215
+ // Electron desktop: the daemon runs as the Electron binary with
216
+ // ELECTRON_RUN_AS_NODE=1, and Electron's fork re-injects that variable, so a
217
+ // PATH-only env still boots the helper as plain Node.
218
+ env: {
219
+ PATH: process.env.PATH ?? "",
220
+ ...(process.env.PASEO_SPAWN_BROKER_TEST_HOOKS === "1"
221
+ ? { PASEO_SPAWN_BROKER_TEST_HOOKS: "1" }
222
+ : {}),
223
+ },
224
+ });
225
+ }
226
+ catch {
227
+ this.recordFailure();
228
+ return null;
229
+ }
230
+ this.child = child;
231
+ child.on("message", (message) => {
232
+ this.consecutiveFailures = 0;
233
+ this.pending.get(message.id)?.handleMessage(message);
234
+ });
235
+ child.on("error", () => this.handleChildLost(child));
236
+ child.on("exit", () => this.handleChildLost(child));
237
+ child.on("disconnect", () => this.handleChildLost(child));
238
+ this.installExitHook();
239
+ this.updateRef();
240
+ return child;
241
+ }
242
+ handleChildLost(child) {
243
+ if (this.child !== child)
244
+ return;
245
+ this.child = null;
246
+ this.recordFailure();
247
+ const lost = [...this.pending.values()];
248
+ this.pending.clear();
249
+ for (const proc of lost) {
250
+ proc.handleBrokerLost();
251
+ }
252
+ }
253
+ recordFailure() {
254
+ this.consecutiveFailures += 1;
255
+ if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
256
+ this.disabledUntil = Date.now() + FAILURE_COOLDOWN_MS;
257
+ this.consecutiveFailures = 0;
258
+ }
259
+ }
260
+ updateRef() {
261
+ const child = this.child;
262
+ if (!child)
263
+ return;
264
+ // Never keep the daemon alive just for the helper; hold a ref only while a
265
+ // request is in flight so awaiting callers are not cut off.
266
+ const channel = child.channel;
267
+ if (this.pending.size > 0) {
268
+ child.ref();
269
+ channel?.ref?.();
270
+ }
271
+ else {
272
+ child.unref();
273
+ channel?.unref?.();
274
+ }
275
+ }
276
+ installExitHook() {
277
+ if (this.exitHookInstalled)
278
+ return;
279
+ this.exitHookInstalled = true;
280
+ process.once("exit", () => {
281
+ try {
282
+ this.child?.kill("SIGKILL");
283
+ }
284
+ catch {
285
+ // already gone
286
+ }
287
+ });
288
+ }
289
+ }
290
+ let sharedBroker = null;
291
+ export function getSharedSpawnBroker() {
292
+ sharedBroker ?? (sharedBroker = new SpawnBroker());
293
+ return sharedBroker;
294
+ }
295
+ export function resetSharedSpawnBrokerForTest() {
296
+ sharedBroker?.dispose();
297
+ sharedBroker = null;
298
+ }
299
+ //# sourceMappingURL=spawn-broker.js.map
@@ -2,6 +2,7 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { extname } from "node:path";
3
3
  import { promisify } from "node:util";
4
4
  import { createExternalCommandProcessEnv } from "../server/paseo-env.js";
5
+ import { getSharedSpawnBroker, isSpawnBrokerEnabled, } from "./spawn-broker.js";
5
6
  import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand, } from "./windows-command.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  function hasPathSeparator(value) {
@@ -16,17 +17,21 @@ function shouldUseWindowsShell(command, requestedShell) {
16
17
  }
17
18
  return process.platform === "win32" && !hasPathSeparator(command) && !extname(command);
18
19
  }
19
- export function spawnProcess(command, args, options) {
20
- const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {};
20
+ function resolveChildEnv(command, options) {
21
+ const { baseEnv, env, envOverlay } = options ?? {};
21
22
  const resolvedBaseEnv = env ?? baseEnv ?? process.env;
23
+ return options?.envMode === "internal"
24
+ ? { ...resolvedBaseEnv, ...envOverlay }
25
+ : createExternalCommandProcessEnv(command, resolvedBaseEnv, ...(envOverlay ? [envOverlay] : []));
26
+ }
27
+ export function spawnProcess(command, args, options) {
28
+ const { baseEnv: _baseEnv, env: _env, envOverlay: _envOverlay, ...spawnOptions } = options ?? {};
22
29
  const isWindows = process.platform === "win32";
23
30
  const shell = shouldUseWindowsShell(command, spawnOptions.shell);
24
31
  const shouldQuoteForShell = isWindows && shell !== false;
25
32
  const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
26
33
  const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
27
- const childEnv = options?.envMode === "internal"
28
- ? { ...resolvedBaseEnv, ...envOverlay }
29
- : createExternalCommandProcessEnv(command, resolvedBaseEnv, ...(envOverlay ? [envOverlay] : []));
34
+ const childEnv = resolveChildEnv(command, options);
30
35
  return spawn(resolvedCommand, resolvedArgs, {
31
36
  ...spawnOptions,
32
37
  env: childEnv,
@@ -34,17 +39,53 @@ export function spawnProcess(command, args, options) {
34
39
  windowsHide: true,
35
40
  });
36
41
  }
42
+ /**
43
+ * Spawn a short-lived command whose stdin is ignored and whose stdout/stderr are
44
+ * piped. Runs through the spawn broker helper process when available (so the
45
+ * daemon does not fork its own large image), and falls back to spawnProcess.
46
+ * Use spawnProcess instead when you need stdin, detached, a pty or a real
47
+ * ChildProcess handle.
48
+ */
49
+ export function spawnNonInteractive(command, args, options) {
50
+ const direct = () => spawnProcess(command, args, {
51
+ cwd: options?.cwd,
52
+ env: options?.env,
53
+ baseEnv: options?.baseEnv,
54
+ envOverlay: options?.envOverlay,
55
+ envMode: options?.envMode,
56
+ shell: options?.shell,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ });
59
+ const shell = shouldUseWindowsShell(command, options?.shell);
60
+ if (!isSpawnBrokerEnabled() || shell !== false) {
61
+ return direct();
62
+ }
63
+ return getSharedSpawnBroker().spawn({
64
+ command,
65
+ args,
66
+ cwd: options?.cwd,
67
+ env: resolveChildEnv(command, options),
68
+ shell: false,
69
+ maxStdoutBytes: options?.maxStdoutBytes,
70
+ maxStderrBytes: options?.maxStderrBytes,
71
+ }, direct, { retryOnDirect: options?.retryOnDirect ?? false });
72
+ }
73
+ const DEFAULT_EXEC_MAX_BUFFER = 1024 * 1024;
37
74
  export async function execCommand(command, args, options) {
38
- const { baseEnv, env, envOverlay } = options ?? {};
39
- const resolvedBaseEnv = env ?? baseEnv ?? process.env;
40
75
  const isWindows = process.platform === "win32";
41
76
  const shell = shouldUseWindowsShell(command, options?.shell);
42
77
  const shouldQuoteForShell = isWindows && shell !== false;
78
+ if (!isSpawnBrokerEnabled() || shell !== false || shouldQuoteForShell) {
79
+ return execCommandDirect(command, args, options, shell);
80
+ }
81
+ return execCommandNonInteractive(command, args, options);
82
+ }
83
+ function execCommandDirect(command, args, options, shell) {
84
+ const isWindows = process.platform === "win32";
85
+ const shouldQuoteForShell = isWindows && shell !== false;
43
86
  const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
44
87
  const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
45
- const childEnv = options?.envMode === "internal"
46
- ? { ...resolvedBaseEnv, ...envOverlay }
47
- : createExternalCommandProcessEnv(command, resolvedBaseEnv, ...(envOverlay ? [envOverlay] : []));
88
+ const childEnv = resolveChildEnv(command, options);
48
89
  return execFileAsync(resolvedCommand, resolvedArgs, {
49
90
  cwd: options?.cwd,
50
91
  env: childEnv,
@@ -56,6 +97,99 @@ export async function execCommand(command, args, options) {
56
97
  windowsHide: true,
57
98
  });
58
99
  }
100
+ /**
101
+ * execFile semantics (timeout + killSignal, maxBuffer, "Command failed" errors
102
+ * carrying code/killed/signal/stdout/stderr) on top of spawnNonInteractive.
103
+ */
104
+ function execCommandNonInteractive(command, args, options) {
105
+ const encoding = options?.encoding ?? "utf8";
106
+ const maxBuffer = options?.maxBuffer ?? DEFAULT_EXEC_MAX_BUFFER;
107
+ const killSignal = options?.killSignal ?? "SIGTERM";
108
+ const cmd = [command, ...args].join(" ");
109
+ return new Promise((resolve, reject) => {
110
+ const child = spawnNonInteractive(command, args, {
111
+ cwd: options?.cwd,
112
+ env: options?.env,
113
+ baseEnv: options?.baseEnv,
114
+ envOverlay: options?.envOverlay,
115
+ envMode: options?.envMode,
116
+ shell: false,
117
+ // One byte over the cap so the overflow is still detected here.
118
+ maxStdoutBytes: maxBuffer + 1,
119
+ maxStderrBytes: maxBuffer + 1,
120
+ });
121
+ const stdoutChunks = [];
122
+ const stderrChunks = [];
123
+ let stdoutLength = 0;
124
+ let stderrLength = 0;
125
+ let killed = false;
126
+ let settled = false;
127
+ let exError = null;
128
+ let timer;
129
+ const decode = (chunks) => Buffer.concat(chunks).toString(encoding);
130
+ const kill = () => {
131
+ killed = true;
132
+ try {
133
+ child.kill(killSignal);
134
+ }
135
+ catch (error) {
136
+ exError = error;
137
+ }
138
+ };
139
+ const finish = (code, signal) => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ if (timer)
144
+ clearTimeout(timer);
145
+ const stdout = decode(stdoutChunks);
146
+ const stderr = decode(stderrChunks);
147
+ if (!exError && code === 0 && signal === null) {
148
+ resolve({ stdout, stderr });
149
+ return;
150
+ }
151
+ const error = exError ?? new Error(`Command failed: ${cmd}\n${stderr}`);
152
+ if (!exError) {
153
+ error.code = code !== null && code < 0 ? String(code) : code;
154
+ error.killed = killed;
155
+ error.signal = signal;
156
+ }
157
+ error.cmd = cmd;
158
+ error.stdout = stdout;
159
+ error.stderr = stderr;
160
+ reject(error);
161
+ };
162
+ const onData = (chunks, stream) => (chunk) => {
163
+ if (settled)
164
+ return;
165
+ const length = stream === "stdout" ? stdoutLength : stderrLength;
166
+ const room = maxBuffer - length;
167
+ if (chunk.length > room) {
168
+ chunks.push(chunk.subarray(0, Math.max(0, room)));
169
+ const error = new RangeError(`${stream} maxBuffer length exceeded`);
170
+ error.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
171
+ exError = error;
172
+ kill();
173
+ return;
174
+ }
175
+ chunks.push(chunk);
176
+ if (stream === "stdout")
177
+ stdoutLength += chunk.length;
178
+ else
179
+ stderrLength += chunk.length;
180
+ };
181
+ child.stdout?.on("data", onData(stdoutChunks, "stdout"));
182
+ child.stderr?.on("data", onData(stderrChunks, "stderr"));
183
+ child.on("error", (error) => {
184
+ exError = error;
185
+ finish(null, null);
186
+ });
187
+ child.on("close", (code, signal) => finish(code, signal));
188
+ if (options?.timeout && options.timeout > 0) {
189
+ timer = setTimeout(kill, options.timeout);
190
+ }
191
+ });
192
+ }
59
193
  export function platformShell() {
60
194
  if (process.platform === "win32") {
61
195
  return { command: "cmd.exe", flag: ["/c"] };