@scelar/nodepod 1.0.0

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 (134) hide show
  1. package/LICENSE +43 -0
  2. package/README.md +240 -0
  3. package/dist/child_process-BJOMsZje.js +8233 -0
  4. package/dist/child_process-BJOMsZje.js.map +1 -0
  5. package/dist/child_process-Cj8vOcuc.cjs +7434 -0
  6. package/dist/child_process-Cj8vOcuc.cjs.map +1 -0
  7. package/dist/index-Cb1Cgdnd.js +35308 -0
  8. package/dist/index-Cb1Cgdnd.js.map +1 -0
  9. package/dist/index-DsMGS-xc.cjs +37195 -0
  10. package/dist/index-DsMGS-xc.cjs.map +1 -0
  11. package/dist/index.cjs +65 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.mjs +59 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +95 -0
  16. package/src/__tests__/smoke.test.ts +11 -0
  17. package/src/constants/cdn-urls.ts +18 -0
  18. package/src/constants/config.ts +236 -0
  19. package/src/cross-origin.ts +26 -0
  20. package/src/engine-factory.ts +176 -0
  21. package/src/engine-types.ts +56 -0
  22. package/src/helpers/byte-encoding.ts +39 -0
  23. package/src/helpers/digest.ts +9 -0
  24. package/src/helpers/event-loop.ts +96 -0
  25. package/src/helpers/wasm-cache.ts +133 -0
  26. package/src/iframe-sandbox.ts +141 -0
  27. package/src/index.ts +192 -0
  28. package/src/isolation-helpers.ts +148 -0
  29. package/src/memory-volume.ts +941 -0
  30. package/src/module-transformer.ts +368 -0
  31. package/src/packages/archive-extractor.ts +248 -0
  32. package/src/packages/browser-bundler.ts +284 -0
  33. package/src/packages/installer.ts +396 -0
  34. package/src/packages/registry-client.ts +131 -0
  35. package/src/packages/version-resolver.ts +411 -0
  36. package/src/polyfills/assert.ts +384 -0
  37. package/src/polyfills/async_hooks.ts +144 -0
  38. package/src/polyfills/buffer.ts +628 -0
  39. package/src/polyfills/child_process.ts +2288 -0
  40. package/src/polyfills/chokidar.ts +336 -0
  41. package/src/polyfills/cluster.ts +106 -0
  42. package/src/polyfills/console.ts +136 -0
  43. package/src/polyfills/constants.ts +123 -0
  44. package/src/polyfills/crypto.ts +885 -0
  45. package/src/polyfills/dgram.ts +87 -0
  46. package/src/polyfills/diagnostics_channel.ts +76 -0
  47. package/src/polyfills/dns.ts +134 -0
  48. package/src/polyfills/domain.ts +68 -0
  49. package/src/polyfills/esbuild.ts +854 -0
  50. package/src/polyfills/events.ts +276 -0
  51. package/src/polyfills/fs.ts +2888 -0
  52. package/src/polyfills/fsevents.ts +79 -0
  53. package/src/polyfills/http.ts +1449 -0
  54. package/src/polyfills/http2.ts +199 -0
  55. package/src/polyfills/https.ts +76 -0
  56. package/src/polyfills/inspector.ts +62 -0
  57. package/src/polyfills/lightningcss.ts +105 -0
  58. package/src/polyfills/module.ts +191 -0
  59. package/src/polyfills/net.ts +353 -0
  60. package/src/polyfills/os.ts +238 -0
  61. package/src/polyfills/path.ts +206 -0
  62. package/src/polyfills/perf_hooks.ts +102 -0
  63. package/src/polyfills/process.ts +690 -0
  64. package/src/polyfills/punycode.ts +159 -0
  65. package/src/polyfills/querystring.ts +93 -0
  66. package/src/polyfills/quic.ts +118 -0
  67. package/src/polyfills/readdirp.ts +229 -0
  68. package/src/polyfills/readline.ts +692 -0
  69. package/src/polyfills/repl.ts +134 -0
  70. package/src/polyfills/rollup.ts +119 -0
  71. package/src/polyfills/sea.ts +33 -0
  72. package/src/polyfills/sqlite.ts +78 -0
  73. package/src/polyfills/stream.ts +1620 -0
  74. package/src/polyfills/string_decoder.ts +25 -0
  75. package/src/polyfills/tailwindcss-oxide.ts +309 -0
  76. package/src/polyfills/test.ts +197 -0
  77. package/src/polyfills/timers.ts +32 -0
  78. package/src/polyfills/tls.ts +105 -0
  79. package/src/polyfills/trace_events.ts +50 -0
  80. package/src/polyfills/tty.ts +71 -0
  81. package/src/polyfills/url.ts +174 -0
  82. package/src/polyfills/util.ts +559 -0
  83. package/src/polyfills/v8.ts +126 -0
  84. package/src/polyfills/vm.ts +132 -0
  85. package/src/polyfills/volume-registry.ts +15 -0
  86. package/src/polyfills/wasi.ts +44 -0
  87. package/src/polyfills/worker_threads.ts +326 -0
  88. package/src/polyfills/ws.ts +595 -0
  89. package/src/polyfills/zlib.ts +881 -0
  90. package/src/request-proxy.ts +716 -0
  91. package/src/script-engine.ts +3375 -0
  92. package/src/sdk/nodepod-fs.ts +93 -0
  93. package/src/sdk/nodepod-process.ts +86 -0
  94. package/src/sdk/nodepod-terminal.ts +350 -0
  95. package/src/sdk/nodepod.ts +509 -0
  96. package/src/sdk/types.ts +70 -0
  97. package/src/shell/commands/bun.ts +121 -0
  98. package/src/shell/commands/directory.ts +297 -0
  99. package/src/shell/commands/file-ops.ts +525 -0
  100. package/src/shell/commands/git.ts +2142 -0
  101. package/src/shell/commands/node.ts +80 -0
  102. package/src/shell/commands/npm.ts +198 -0
  103. package/src/shell/commands/pm-types.ts +45 -0
  104. package/src/shell/commands/pnpm.ts +82 -0
  105. package/src/shell/commands/search.ts +264 -0
  106. package/src/shell/commands/shell-env.ts +352 -0
  107. package/src/shell/commands/text-processing.ts +1152 -0
  108. package/src/shell/commands/yarn.ts +84 -0
  109. package/src/shell/shell-builtins.ts +19 -0
  110. package/src/shell/shell-helpers.ts +250 -0
  111. package/src/shell/shell-interpreter.ts +514 -0
  112. package/src/shell/shell-parser.ts +429 -0
  113. package/src/shell/shell-types.ts +85 -0
  114. package/src/syntax-transforms.ts +561 -0
  115. package/src/threading/engine-worker.ts +64 -0
  116. package/src/threading/inline-worker.ts +372 -0
  117. package/src/threading/offload-types.ts +112 -0
  118. package/src/threading/offload-worker.ts +383 -0
  119. package/src/threading/offload.ts +271 -0
  120. package/src/threading/process-context.ts +92 -0
  121. package/src/threading/process-handle.ts +275 -0
  122. package/src/threading/process-manager.ts +956 -0
  123. package/src/threading/process-worker-entry.ts +854 -0
  124. package/src/threading/shared-vfs.ts +352 -0
  125. package/src/threading/sync-channel.ts +135 -0
  126. package/src/threading/task-queue.ts +177 -0
  127. package/src/threading/vfs-bridge.ts +231 -0
  128. package/src/threading/worker-pool.ts +233 -0
  129. package/src/threading/worker-protocol.ts +358 -0
  130. package/src/threading/worker-vfs.ts +218 -0
  131. package/src/types/externals.d.ts +38 -0
  132. package/src/types/fs-streams.ts +142 -0
  133. package/src/types/manifest.ts +17 -0
  134. package/src/worker-sandbox.ts +90 -0
@@ -0,0 +1,2288 @@
1
+ // child_process polyfill -- exec, execSync, spawn, fork backed by NodepodShell.
2
+ // Integrates with MemoryVolume directly.
3
+
4
+ import { NodepodShell } from "../shell/shell-interpreter";
5
+ import type { ShellResult, ShellContext } from "../shell/shell-types";
6
+ import { EventEmitter } from "./events";
7
+ import { Readable, Writable } from "./stream";
8
+ import { Buffer } from "./buffer";
9
+ import type { MemoryVolume } from "../memory-volume";
10
+ import { ScriptEngine } from "../script-engine";
11
+ import type { PackageManifest } from "../types/manifest";
12
+ import { resetActiveInterfaceCount } from "./readline";
13
+ import { ref, unref, getRefCount, resetRefCount, addDrainListener } from "../helpers/event-loop";
14
+ import { getActiveContext, setActiveContext } from "../threading/process-context";
15
+ import type { ProcessContext } from "../threading/process-context";
16
+ import type { PmDeps, PkgManager } from "../shell/commands/pm-types";
17
+ import { createNpmCommand } from "../shell/commands/npm";
18
+ import { createPnpmCommand } from "../shell/commands/pnpm";
19
+ import { createYarnCommand } from "../shell/commands/yarn";
20
+ import { createBunCommand, createBunxCommand } from "../shell/commands/bun";
21
+ import { createNodeCommand, createNpxCommand } from "../shell/commands/node";
22
+ import { createGitCommand } from "../shell/commands/git";
23
+ import { format as utilFormat } from "./util";
24
+ import { VERSIONS, NPM_REGISTRY_URL_SLASH, TIMEOUTS, DEFAULT_ENV, MOCK_PID } from "../constants/config";
25
+ import { closeAllServers, getAllServers } from "./http";
26
+ import type { SyncChannelWorker } from "../threading/sync-channel";
27
+
28
+ let _shell: NodepodShell | null = null;
29
+ let _vol: MemoryVolume | null = null;
30
+
31
+ let _syncChannel: SyncChannelWorker | null = null;
32
+
33
+ let _stdoutSink: ((text: string) => void) | null = null;
34
+ let _stderrSink: ((text: string) => void) | null = null;
35
+ let _haltSignal: AbortSignal | null = null;
36
+
37
+ let _termCols: (() => number) | null = null;
38
+ let _termRows: (() => number) | null = null;
39
+
40
+ let _rawModeChangeCb: ((isRaw: boolean) => void) | null = null;
41
+
42
+ // context-aware state accessors: check ProcessContext first, fall back to module globals
43
+
44
+ function getStdoutSink(): ((text: string) => void) | null {
45
+ const ctx = getActiveContext();
46
+ return ctx?.stdoutSink ?? _stdoutSink;
47
+ }
48
+
49
+ function getStderrSink(): ((text: string) => void) | null {
50
+ const ctx = getActiveContext();
51
+ return ctx?.stderrSink ?? _stderrSink;
52
+ }
53
+
54
+ function getHaltSignal(): AbortSignal | null {
55
+ const ctx = getActiveContext();
56
+ return ctx ? ctx.abortController.signal : _haltSignal;
57
+ }
58
+
59
+ function getLiveStdin(): { emit: (e: string, ...a: unknown[]) => void } | null {
60
+ const ctx = getActiveContext();
61
+ return ctx?.liveStdin ?? _liveStdin;
62
+ }
63
+
64
+ function getTermCols(): number {
65
+ const ctx = getActiveContext();
66
+ return ctx?.termCols?.() ?? _termCols?.() ?? 80;
67
+ }
68
+
69
+ function getTermRows(): number {
70
+ const ctx = getActiveContext();
71
+ return ctx?.termRows?.() ?? _termRows?.() ?? 24;
72
+ }
73
+
74
+ function formatThrown(e: unknown): string {
75
+ if (e instanceof Error) {
76
+ const prefix =
77
+ e.constructor?.name && e.constructor.name !== "Error"
78
+ ? `${e.constructor.name}: `
79
+ : "";
80
+ let msg = prefix + (e.message || e.name || "Unknown error");
81
+ if (e.stack) msg += "\n" + e.stack;
82
+ return msg;
83
+ }
84
+ if (e === null || e === undefined) return "Script threw a falsy value";
85
+ return String(e) || "Unknown error (non-Error object thrown)";
86
+ }
87
+
88
+ export function setStreamingCallbacks(cfg: {
89
+ onStdout?: (t: string) => void;
90
+ onStderr?: (t: string) => void;
91
+ signal?: AbortSignal;
92
+ getCols?: () => number;
93
+ getRows?: () => number;
94
+ onRawModeChange?: (isRaw: boolean) => void;
95
+ }): void {
96
+ _stdoutSink = cfg.onStdout ?? null;
97
+ _stderrSink = cfg.onStderr ?? null;
98
+ _haltSignal = cfg.signal ?? null;
99
+ _termCols = cfg.getCols ?? null;
100
+ _termRows = cfg.getRows ?? null;
101
+ _rawModeChangeCb = cfg.onRawModeChange ?? null;
102
+
103
+ // also update active ProcessContext if present
104
+ const ctx = getActiveContext();
105
+ if (ctx) {
106
+ ctx.stdoutSink = cfg.onStdout ?? null;
107
+ ctx.stderrSink = cfg.onStderr ?? null;
108
+ if (cfg.signal) {
109
+ cfg.signal.addEventListener("abort", () => ctx.abortController.abort(), { once: true });
110
+ }
111
+ ctx.termCols = cfg.getCols ?? null;
112
+ ctx.termRows = cfg.getRows ?? null;
113
+ }
114
+ }
115
+
116
+ export function clearStreamingCallbacks(): void {
117
+ _stdoutSink = null;
118
+ _stderrSink = null;
119
+ _haltSignal = null;
120
+ _termCols = null;
121
+ _termRows = null;
122
+ _rawModeChangeCb = null;
123
+
124
+ // also clear active ProcessContext if present
125
+ const ctx = getActiveContext();
126
+ if (ctx) {
127
+ ctx.stdoutSink = null;
128
+ ctx.stderrSink = null;
129
+ ctx.termCols = null;
130
+ ctx.termRows = null;
131
+ }
132
+ }
133
+
134
+ // set the SyncChannelWorker for true blocking execSync/spawnSync in worker mode
135
+ export function setSyncChannel(channel: SyncChannelWorker): void {
136
+ _syncChannel = channel;
137
+ }
138
+
139
+ // onStdout/onStderr fire in real-time as output arrives; promise resolves on child exit
140
+ export type SpawnChildCallback = (
141
+ command: string,
142
+ args: string[],
143
+ opts?: {
144
+ cwd?: string;
145
+ env?: Record<string, string>;
146
+ stdio?: "pipe" | "inherit";
147
+ onStdout?: (data: string) => void;
148
+ onStderr?: (data: string) => void;
149
+ },
150
+ ) => Promise<{ pid: number; exitCode: number; stdout: string; stderr: string }>;
151
+
152
+ let _spawnChildFn: SpawnChildCallback | null = null;
153
+
154
+ export function setSpawnChildCallback(fn: SpawnChildCallback): void {
155
+ _spawnChildFn = fn;
156
+ }
157
+
158
+ // returns ForkHandle immediately; onExit fires when the child exits
159
+ export type ForkChildCallback = (
160
+ modulePath: string,
161
+ args: string[],
162
+ opts: {
163
+ cwd: string;
164
+ env: Record<string, string>;
165
+ onStdout?: (data: string) => void;
166
+ onStderr?: (data: string) => void;
167
+ onIPC?: (data: unknown) => void;
168
+ onExit?: (exitCode: number) => void;
169
+ },
170
+ ) => {
171
+ sendIPC: (data: unknown) => void;
172
+ disconnect: () => void;
173
+ requestId: number;
174
+ };
175
+
176
+ let _forkChildFn: ForkChildCallback | null = null;
177
+
178
+ export function setForkChildCallback(fn: ForkChildCallback): void {
179
+ _forkChildFn = fn;
180
+ }
181
+
182
+ // IPC plumbing for when this worker IS a forked child
183
+ let _ipcSendFn: ((data: unknown) => void) | null = null;
184
+ let _ipcReceiveHandler: ((data: unknown) => void) | null = null;
185
+ // messages that arrive before the handler is wired (parent sends before child's ENB sets up the handler)
186
+ let _ipcQueue: unknown[] = [];
187
+
188
+ export function setIPCSend(fn: (data: unknown) => void): void {
189
+ _ipcSendFn = fn;
190
+ }
191
+
192
+ export function setIPCReceiveHandler(fn: (data: unknown) => void): void {
193
+ _ipcReceiveHandler = fn;
194
+ // replay any messages that arrived before the handler was set
195
+ if (_ipcQueue.length > 0) {
196
+ const queued = _ipcQueue;
197
+ _ipcQueue = [];
198
+ for (const msg of queued) fn(msg);
199
+ }
200
+ }
201
+
202
+ // called by process-worker-entry when an IPC message arrives
203
+ export function handleIPCFromParent(data: unknown): void {
204
+ if (_ipcReceiveHandler) {
205
+ _ipcReceiveHandler(data);
206
+ } else {
207
+ // handler not wired yet, queue for replay
208
+ _ipcQueue.push(data);
209
+ }
210
+ }
211
+
212
+ export function getShellCwd(): string {
213
+ return _shell?.getCwd() ?? "/";
214
+ }
215
+
216
+ // called by process.chdir() so subsequent exec/spawn without explicit cwd pick up the new dir
217
+ export function setShellCwd(dir: string): void {
218
+ if (_shell) _shell.setCwd(dir);
219
+ }
220
+
221
+ // runs command inline in the current worker via NodepodShell (NOT a child process).
222
+ // child_process.exec() spawns a new worker; shellExec() runs in THIS process.
223
+ export function shellExec(
224
+ cmd: string,
225
+ opts: { cwd?: string; env?: Record<string, string> },
226
+ callback: (error: Error | null, stdout: string, stderr: string) => void,
227
+ ): void {
228
+ if (!_shell) {
229
+ callback(new Error("[Nodepod] Shell not initialized"), "", "");
230
+ return;
231
+ }
232
+ _shell.exec(cmd, opts).then(
233
+ (result) => {
234
+ if (result.exitCode !== 0) {
235
+ const e = new Error(`Command failed: ${cmd}`);
236
+ (e as any).code = result.exitCode;
237
+ callback(e, result.stdout, result.stderr);
238
+ } else {
239
+ callback(null, result.stdout, result.stderr);
240
+ }
241
+ },
242
+ (e) => {
243
+ callback(e instanceof Error ? e : new Error(String(e)), "", "");
244
+ },
245
+ );
246
+ }
247
+
248
+ let _liveStdin: { emit: (e: string, ...a: unknown[]) => void } | null = null;
249
+
250
+ // check if the live process stdin is in raw mode (library handles its own echo)
251
+ export function isStdinRaw(): boolean {
252
+ const stdin = getLiveStdin();
253
+ if (!stdin) return false;
254
+ return !!(stdin as any).isRaw;
255
+ }
256
+
257
+ export function sendStdin(text: string): void {
258
+ const stdin = getLiveStdin();
259
+ if (!stdin) {
260
+ return;
261
+ }
262
+ // emit 'data' only -- readline.emitKeypressEvents() parses data into 'keypress' events
263
+ // automatically, matching real Node.js
264
+ stdin.emit("data", text);
265
+ }
266
+
267
+ export function initShellExec(volume: MemoryVolume, opts?: { cwd?: string; env?: Record<string, string> }): void {
268
+ _vol = volume;
269
+
270
+ _shell = new NodepodShell(volume, {
271
+ cwd: opts?.cwd ?? "/",
272
+ env: {
273
+ HOME: "/home/user",
274
+ USER: "user",
275
+ PATH: "/usr/local/bin:/usr/bin:/bin:/node_modules/.bin",
276
+ NODE_ENV: "development",
277
+ TERM: "xterm-256color",
278
+ COLORTERM: "truecolor",
279
+ npm_config_user_agent: DEFAULT_ENV.npm_config_user_agent,
280
+ npm_execpath: DEFAULT_ENV.npm_execpath,
281
+ npm_node_execpath: DEFAULT_ENV.npm_node_execpath,
282
+ ...opts?.env,
283
+ },
284
+ });
285
+
286
+ const pmDeps: PmDeps = {
287
+ installPackages,
288
+ uninstallPackages,
289
+ listPackages,
290
+ runScript,
291
+ npmInitOrCreate,
292
+ npmInfo,
293
+ npmPack,
294
+ npmConfig,
295
+ npxExecute,
296
+ executeNodeBinary,
297
+ evalCode: (code, ctx) => evalNodeCode(code, ctx),
298
+ printCode: (code, ctx) => printNodeCode(code, ctx),
299
+ removeNodeModules: (cwd) => {
300
+ const dir = `${cwd}/node_modules`.replace(/\/+/g, "/");
301
+ if (_vol!.existsSync(dir)) removeDir(_vol!, dir);
302
+ },
303
+ formatErr,
304
+ formatWarn,
305
+ hasFile: (p) => !!_vol && _vol.existsSync(p),
306
+ readFile: (p) => _vol!.readFileSync(p, "utf8") as string,
307
+ writeFile: (p, data) => _vol!.writeFileSync(p, data),
308
+ };
309
+
310
+ _shell.registerCommand(createNodeCommand(pmDeps));
311
+ _shell.registerCommand(createNpxCommand(pmDeps));
312
+ _shell.registerCommand(createNpmCommand(pmDeps));
313
+ _shell.registerCommand(createPnpmCommand(pmDeps));
314
+ _shell.registerCommand(createYarnCommand(pmDeps));
315
+ _shell.registerCommand(createBunCommand(pmDeps));
316
+ _shell.registerCommand(createBunxCommand(pmDeps));
317
+ _shell.registerCommand(createGitCommand());
318
+ }
319
+
320
+ // node -e / -p helpers (used by PmDeps)
321
+
322
+ function evalNodeCode(code: string, ctx: ShellContext): ShellResult {
323
+ let out = "";
324
+ let err = "";
325
+ const sandbox = new ScriptEngine(_vol!, {
326
+ cwd: ctx.cwd,
327
+ env: ctx.env,
328
+ onConsole: (m: string, args: unknown[]) => {
329
+ const line = utilFormat(args[0], ...args.slice(1)) + "\n";
330
+ m === "error" ? (err += line) : (out += line);
331
+ },
332
+ onStdout: (s: string) => {
333
+ out += s;
334
+ },
335
+ onStderr: (s: string) => {
336
+ err += s;
337
+ },
338
+ });
339
+ try {
340
+ sandbox.execute(code, "/<eval>.js");
341
+ } catch (e) {
342
+ if (e instanceof Error && e.message.startsWith("Process exited with code"))
343
+ return { stdout: out, stderr: err, exitCode: 0 };
344
+ err += `Error: ${e instanceof Error ? e.message : String(e)}\n`;
345
+ return { stdout: out, stderr: err, exitCode: 1 };
346
+ }
347
+ return { stdout: out, stderr: err, exitCode: 0 };
348
+ }
349
+
350
+ function printNodeCode(code: string, ctx: ShellContext): ShellResult {
351
+ let out = "";
352
+ let err = "";
353
+ const sandbox = new ScriptEngine(_vol!, {
354
+ cwd: ctx.cwd,
355
+ env: ctx.env,
356
+ onConsole: (m: string, args: unknown[]) => {
357
+ const line = utilFormat(args[0], ...args.slice(1)) + "\n";
358
+ m === "error" ? (err += line) : (out += line);
359
+ },
360
+ onStdout: (s: string) => {
361
+ out += s;
362
+ },
363
+ onStderr: (s: string) => {
364
+ err += s;
365
+ },
366
+ });
367
+ try {
368
+ const result = sandbox.execute(code, "/<print>.js");
369
+ out += String(result.exports) + "\n";
370
+ } catch (e) {
371
+ err += `Error: ${e instanceof Error ? e.message : String(e)}\n`;
372
+ return { stdout: out, stderr: err, exitCode: 1 };
373
+ }
374
+ return { stdout: out, stderr: err, exitCode: 0 };
375
+ }
376
+
377
+ // npm helpers
378
+
379
+ function removeDir(vol: MemoryVolume, dir: string): void {
380
+ for (const name of vol.readdirSync(dir)) {
381
+ const full = `${dir}/${name}`;
382
+ const st = vol.statSync(full);
383
+ if (st.isDirectory()) removeDir(vol, full);
384
+ else vol.unlinkSync(full);
385
+ }
386
+ vol.rmdirSync(dir);
387
+ }
388
+
389
+ function loadManifest(
390
+ cwd: string,
391
+ ): { pkg: PackageManifest } | { fail: ShellResult } {
392
+ const p = `${cwd}/package.json`.replace(/\/+/g, "/");
393
+ if (!_vol!.existsSync(p))
394
+ return {
395
+ fail: {
396
+ stdout: "",
397
+ stderr: formatErr("package.json not found", "npm"),
398
+ exitCode: 1,
399
+ },
400
+ };
401
+ try {
402
+ return {
403
+ pkg: JSON.parse(_vol!.readFileSync(p, "utf8")) as PackageManifest,
404
+ };
405
+ } catch {
406
+ return {
407
+ fail: {
408
+ stdout: "",
409
+ stderr: formatErr("Malformed package.json", "npm"),
410
+ exitCode: 1,
411
+ },
412
+ };
413
+ }
414
+ }
415
+
416
+ async function runScript(
417
+ args: string[],
418
+ ctx: ShellContext,
419
+ ): Promise<ShellResult> {
420
+ const name = args[0];
421
+ if (!name) {
422
+ const r = loadManifest(ctx.cwd);
423
+ if ("fail" in r) return r.fail;
424
+ const scripts = r.pkg.scripts ?? {};
425
+ const keys = Object.keys(scripts);
426
+ if (keys.length === 0) return { stdout: "", stderr: "", exitCode: 0 };
427
+ let text = `Scripts in ${r.pkg.name ?? ""}:\n`;
428
+ for (const k of keys) text += ` ${k}\n ${scripts[k]}\n`;
429
+ return { stdout: text, stderr: "", exitCode: 0 };
430
+ }
431
+
432
+ // extra arguments after "--" separator (npm run dev -- --webpack)
433
+ const dashIdx = args.indexOf("--");
434
+ const extraArgs = dashIdx >= 0 ? args.slice(dashIdx + 1) : [];
435
+
436
+ const r = loadManifest(ctx.cwd);
437
+ if ("fail" in r) return r.fail;
438
+ const scripts = r.pkg.scripts ?? {};
439
+ let cmd = scripts[name];
440
+ if (!cmd) {
441
+ let msg = formatErr(`Missing script: "${name}"`, "npm");
442
+ const avail = Object.keys(scripts);
443
+ if (avail.length) {
444
+ msg += "\nAvailable:\n";
445
+ for (const s of avail)
446
+ msg += ` ${A_CYAN}${s}${A_RESET}: ${A_DIM}${scripts[s]}${A_RESET}\n`;
447
+ }
448
+ return { stdout: "", stderr: msg, exitCode: 1 };
449
+ }
450
+
451
+ // append extra args after "--" to the script command (real npm behavior)
452
+ if (extraArgs.length > 0) {
453
+ cmd += " " + extraArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ");
454
+ }
455
+
456
+ // prepend cwd's node_modules/.bin to PATH (matches real npm behavior)
457
+ const binDir = `${ctx.cwd}/node_modules/.bin`.replace(/\/+/g, "/");
458
+ const existingPath = ctx.env.PATH || "";
459
+ const pathWithBin = existingPath.includes(binDir)
460
+ ? existingPath
461
+ : `${binDir}:${existingPath}`;
462
+
463
+ const env: Record<string, string> = {
464
+ ...ctx.env,
465
+ PATH: pathWithBin,
466
+ npm_lifecycle_event: name,
467
+ };
468
+ if (r.pkg.name) env.npm_package_name = r.pkg.name;
469
+ if (r.pkg.version) env.npm_package_version = r.pkg.version;
470
+
471
+ let allOut = "";
472
+ let allErr = "";
473
+ const label = `${r.pkg.name ?? ""}@${r.pkg.version ?? ""}`;
474
+
475
+ const pre = scripts[`pre${name}`];
476
+ if (pre) {
477
+ const hdr = `\n> ${label} pre${name}\n> ${pre}\n\n`;
478
+ allErr += hdr;
479
+ if (_stderrSink) _stderrSink(hdr);
480
+ const pr = await ctx.exec(pre, { cwd: ctx.cwd, env });
481
+ allOut += pr.stdout;
482
+ allErr += pr.stderr;
483
+ if (pr.exitCode !== 0)
484
+ return { stdout: allOut, stderr: allErr, exitCode: pr.exitCode };
485
+ }
486
+
487
+ const mainHdr = `\n> ${label} ${name}\n> ${cmd}\n\n`;
488
+ allErr += mainHdr;
489
+ if (_stderrSink) _stderrSink(mainHdr);
490
+ const mr = await ctx.exec(cmd, { cwd: ctx.cwd, env });
491
+ allOut += mr.stdout;
492
+ allErr += mr.stderr;
493
+ if (mr.exitCode !== 0)
494
+ return { stdout: allOut, stderr: allErr, exitCode: mr.exitCode };
495
+
496
+ const post = scripts[`post${name}`];
497
+ if (post) {
498
+ const hdr = `\n> ${label} post${name}\n> ${post}\n\n`;
499
+ allErr += hdr;
500
+ if (_stderrSink) _stderrSink(hdr);
501
+ const po = await ctx.exec(post, { cwd: ctx.cwd, env });
502
+ allOut += po.stdout;
503
+ allErr += po.stderr;
504
+ if (po.exitCode !== 0)
505
+ return { stdout: allOut, stderr: allErr, exitCode: po.exitCode };
506
+ }
507
+
508
+ return { stdout: allOut, stderr: allErr, exitCode: 0 };
509
+ }
510
+
511
+ // ANSI + spinner helpers for npm install output
512
+
513
+ const A_RESET = "\x1b[0m";
514
+ const A_BOLD = "\x1b[1m";
515
+ const A_DIM = "\x1b[2m";
516
+ const A_RED = "\x1b[31m";
517
+ const A_GREEN = "\x1b[32m";
518
+ const A_YELLOW = "\x1b[33m";
519
+ const A_BLUE = "\x1b[34m";
520
+ const A_MAGENTA = "\x1b[35m";
521
+ const A_CYAN = "\x1b[36m";
522
+ const A_WHITE = "\x1b[37m";
523
+ const ERASE_LINE = "\x1b[2K";
524
+
525
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
526
+
527
+ function createSpinner(text: string, writeFn: (s: string) => void) {
528
+ let frame = 0;
529
+ let current = text;
530
+ const id = setInterval(() => {
531
+ writeFn(
532
+ `${ERASE_LINE}\r${A_CYAN}${SPINNER_FRAMES[frame]}${A_RESET} ${current}`,
533
+ );
534
+ frame = (frame + 1) % SPINNER_FRAMES.length;
535
+ }, 80);
536
+
537
+ return {
538
+ update(t: string) {
539
+ current = t;
540
+ },
541
+ succeed(t: string) {
542
+ clearInterval(id);
543
+ writeFn(`${ERASE_LINE}\r${A_GREEN}✔${A_RESET} ${t}\n`);
544
+ },
545
+ fail(t: string) {
546
+ clearInterval(id);
547
+ writeFn(`${ERASE_LINE}\r${A_RED}✖${A_RESET} ${t}\n`);
548
+ },
549
+ stop() {
550
+ clearInterval(id);
551
+ },
552
+ };
553
+ }
554
+
555
+ // per-PM accent colors
556
+ const PM_COLORS: Record<PkgManager, string> = {
557
+ npm: A_RED,
558
+ pnpm: A_YELLOW,
559
+ yarn: A_BLUE,
560
+ bun: A_WHITE,
561
+ };
562
+
563
+ function formatProgress(msg: string, pm: PkgManager = "npm"): string {
564
+ const accent = PM_COLORS[pm];
565
+
566
+ const resolving = msg.match(/^Resolving\s+(.+?)\.{3}$/);
567
+ if (resolving)
568
+ return `${A_DIM}Resolving${A_RESET} ${accent}${resolving[1]}${A_RESET}${A_DIM}...${A_RESET}`;
569
+
570
+ const downloading = msg.match(/^Downloading\s+(\d+)\s+package/);
571
+ if (downloading)
572
+ return `${A_DIM}Downloading${A_RESET} ${A_YELLOW}${downloading[1]}${A_RESET} ${A_DIM}packages...${A_RESET}`;
573
+
574
+ const fetching = msg.match(/^(?:\s*)?Fetching\s+(.+?)\.{3}$/);
575
+ if (fetching)
576
+ return `${A_DIM}Fetching${A_RESET} ${accent}${fetching[1]}${A_RESET}${A_DIM}...${A_RESET}`;
577
+
578
+ const transformed = msg.match(/^(?:\s*)?Transformed\s+(\d+)\s+file/);
579
+ if (transformed) return `${A_DIM}${msg.trim()}${A_RESET}`;
580
+
581
+ const installed = msg.match(/^Installed\s+(\d+)/);
582
+ if (installed) return `${A_GREEN}${msg}${A_RESET}`;
583
+
584
+ const skipping = msg.match(/^Skipping\s+(.+?)\s+\(up to date\)$/);
585
+ if (skipping)
586
+ return `${A_DIM}Skipping${A_RESET} ${accent}${skipping[1]}${A_RESET} ${A_DIM}(up to date)${A_RESET}`;
587
+
588
+ return msg;
589
+ }
590
+
591
+ function formatInstallSummary(
592
+ totalAdded: number,
593
+ elapsed: string,
594
+ pm: PkgManager,
595
+ ): string {
596
+ const pkgs = `${totalAdded} package${totalAdded !== 1 ? "s" : ""}`;
597
+ switch (pm) {
598
+ case "npm":
599
+ return `${A_BOLD}added ${pkgs}${A_RESET} ${A_DIM}in ${elapsed}s${A_RESET}`;
600
+ case "pnpm":
601
+ return `${A_BOLD}packages:${A_RESET} ${A_GREEN}+${totalAdded}${A_RESET}\n${A_DIM}Done in ${elapsed}s${A_RESET}`;
602
+ case "yarn":
603
+ return `${A_BOLD}${pkgs} added${A_RESET} ${A_DIM}in ${elapsed}s${A_RESET}`;
604
+ case "bun":
605
+ return `${A_BOLD}${pkgs} installed${A_RESET} ${A_DIM}[${elapsed}s]${A_RESET}`;
606
+ }
607
+ }
608
+
609
+ function formatErr(msg: string, pm: PkgManager): string {
610
+ switch (pm) {
611
+ case "npm":
612
+ return `${A_RED}npm ERR!${A_RESET} ${msg}\n`;
613
+ case "pnpm":
614
+ return `${A_RED} ERR_PNPM${A_RESET} ${msg}\n`;
615
+ case "yarn":
616
+ return `${A_RED}error${A_RESET} ${msg}\n`;
617
+ case "bun":
618
+ return `${A_RED}error:${A_RESET} ${msg}\n`;
619
+ }
620
+ }
621
+
622
+ function formatWarn(msg: string, pm: PkgManager): string {
623
+ switch (pm) {
624
+ case "npm":
625
+ return `${A_YELLOW}npm WARN${A_RESET} ${msg}\n`;
626
+ case "pnpm":
627
+ return `${A_YELLOW} WARN${A_RESET} ${msg}\n`;
628
+ case "yarn":
629
+ return `${A_YELLOW}warning${A_RESET} ${msg}\n`;
630
+ case "bun":
631
+ return `${A_YELLOW}warn:${A_RESET} ${msg}\n`;
632
+ }
633
+ }
634
+
635
+ async function installPackages(
636
+ args: string[],
637
+ ctx: ShellContext,
638
+ pm: PkgManager = "npm",
639
+ ): Promise<ShellResult> {
640
+ const { DependencyInstaller } = await import("../packages/installer");
641
+ const installer = new DependencyInstaller(_vol!, { cwd: ctx.cwd });
642
+ let out = "";
643
+ const write = _stdoutSink ?? ((_s: string) => {});
644
+ const startTime = Date.now();
645
+ const accent = PM_COLORS[pm];
646
+
647
+ const spinnerText =
648
+ pm === "bun"
649
+ ? `${A_DIM}bun install${A_RESET} ${A_DIM}${VERSIONS.BUN_V}${A_RESET}`
650
+ : `${A_DIM}Resolving dependencies...${A_RESET}`;
651
+ const spinner = createSpinner(spinnerText, write);
652
+
653
+ try {
654
+ const names = args.filter((a: string) => !a.startsWith("-"));
655
+ const onProgress = (m: string) => {
656
+ const colored = formatProgress(m, pm);
657
+ out += m + "\n";
658
+ spinner.update(colored);
659
+ };
660
+
661
+ let totalAdded = 0;
662
+ if (names.length === 0) {
663
+ const ir = await installer.installFromManifest(undefined, {
664
+ withDevDeps: true,
665
+ onProgress,
666
+ });
667
+ totalAdded = ir.newPackages.length;
668
+ } else {
669
+ for (const n of names) {
670
+ const ir = await installer.install(n, undefined, { onProgress });
671
+ totalAdded += ir.newPackages.length;
672
+ }
673
+ }
674
+
675
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
676
+ const summary = formatInstallSummary(totalAdded, elapsed, pm);
677
+ spinner.succeed(summary);
678
+ out += `added ${totalAdded} packages in ${elapsed}s\n`;
679
+
680
+ return { stdout: out, stderr: "", exitCode: 0 };
681
+ } catch (e) {
682
+ const msg = e instanceof Error ? e.message : String(e);
683
+ spinner.fail(`${A_RED}${msg}${A_RESET}`);
684
+ return {
685
+ stdout: out,
686
+ stderr: formatErr(msg, pm),
687
+ exitCode: 1,
688
+ };
689
+ }
690
+ }
691
+
692
+ async function uninstallPackages(
693
+ args: string[],
694
+ ctx: ShellContext,
695
+ pm: PkgManager = "npm",
696
+ ): Promise<ShellResult> {
697
+ const names = args.filter((a) => !a.startsWith("-"));
698
+ if (names.length === 0)
699
+ return {
700
+ stdout: "",
701
+ stderr: formatErr("Must specify package to remove", pm),
702
+ exitCode: 1,
703
+ };
704
+
705
+ const write = _stdoutSink ?? ((_s: string) => {});
706
+ let out = "";
707
+ for (const name of names) {
708
+ const pkgDir = `${ctx.cwd}/node_modules/${name}`.replace(/\/+/g, "/");
709
+ if (_vol!.existsSync(pkgDir)) {
710
+ try {
711
+ removeDir(_vol!, pkgDir);
712
+ const msg =
713
+ pm === "bun"
714
+ ? `${A_DIM}-${A_RESET} ${name}`
715
+ : pm === "pnpm"
716
+ ? `${A_RED}-${A_RESET} ${name}`
717
+ : `removed ${name}`;
718
+ out += msg + "\n";
719
+ write(msg + "\n");
720
+ } catch (e) {
721
+ return {
722
+ stdout: out,
723
+ stderr: formatErr(
724
+ `Failed to remove ${name}: ${e instanceof Error ? e.message : String(e)}`,
725
+ pm,
726
+ ),
727
+ exitCode: 1,
728
+ };
729
+ }
730
+ } else {
731
+ out += formatWarn(`${name} not installed`, pm);
732
+ }
733
+
734
+ const r = loadManifest(ctx.cwd);
735
+ if (!("fail" in r)) {
736
+ const pkg = r.pkg;
737
+ let changed = false;
738
+ if (pkg.dependencies?.[name]) {
739
+ delete pkg.dependencies[name];
740
+ changed = true;
741
+ }
742
+ if (pkg.devDependencies?.[name]) {
743
+ delete pkg.devDependencies[name];
744
+ changed = true;
745
+ }
746
+ if (changed) {
747
+ const p = `${ctx.cwd}/package.json`.replace(/\/+/g, "/");
748
+ _vol!.writeFileSync(p, JSON.stringify(pkg, null, 2));
749
+ }
750
+ }
751
+ }
752
+
753
+ return { stdout: out, stderr: "", exitCode: 0 };
754
+ }
755
+
756
+ async function listPackages(
757
+ ctx: ShellContext,
758
+ pm: PkgManager = "npm",
759
+ ): Promise<ShellResult> {
760
+ const { DependencyInstaller } = await import("../packages/installer");
761
+ const installer = new DependencyInstaller(_vol!, { cwd: ctx.cwd });
762
+ const pkgs = installer.listInstalled();
763
+ const entries = Object.entries(pkgs);
764
+ if (entries.length === 0)
765
+ return { stdout: `${A_DIM}(empty)${A_RESET}\n`, stderr: "", exitCode: 0 };
766
+
767
+ const r = loadManifest(ctx.cwd);
768
+ const label = !("fail" in r)
769
+ ? `${r.pkg.name ?? "project"}@${r.pkg.version ?? "0.0.0"}`
770
+ : ctx.cwd;
771
+
772
+ let text = "";
773
+ switch (pm) {
774
+ case "npm":
775
+ text += `${label} ${ctx.cwd}\n`;
776
+ for (let i = 0; i < entries.length; i++) {
777
+ const [n, v] = entries[i];
778
+ const isLast = i === entries.length - 1;
779
+ text += `${isLast ? "└──" : "├──"} ${n}@${A_DIM}${v}${A_RESET}\n`;
780
+ }
781
+ break;
782
+ case "pnpm":
783
+ text += `${A_DIM}Legend: production dependency, optional only, dev only${A_RESET}\n\n`;
784
+ text += `${label} ${ctx.cwd}\n\n`;
785
+ text += `${A_BOLD}dependencies:${A_RESET}\n`;
786
+ for (const [n, v] of entries) text += `${n} ${A_DIM}${v}${A_RESET}\n`;
787
+ break;
788
+ case "yarn":
789
+ text += `${A_BOLD}${label}${A_RESET}\n`;
790
+ for (let i = 0; i < entries.length; i++) {
791
+ const [n, v] = entries[i];
792
+ const isLast = i === entries.length - 1;
793
+ text += `${isLast ? "└─" : "├─"} ${n}@${A_CYAN}${v}${A_RESET}\n`;
794
+ }
795
+ break;
796
+ case "bun":
797
+ for (const [n, v] of entries) text += `${n}@${A_DIM}${v}${A_RESET}\n`;
798
+ text += `\n${A_DIM}${entries.length} packages installed${A_RESET}\n`;
799
+ break;
800
+ }
801
+ return { stdout: text, stderr: "", exitCode: 0 };
802
+ }
803
+
804
+ async function npmInitOrCreate(
805
+ args: string[],
806
+ sub: string,
807
+ ctx: ShellContext,
808
+ ): Promise<ShellResult> {
809
+ const flags = args.filter((a) => a.startsWith("-"));
810
+ const positional = args.filter((a) => !a.startsWith("-"));
811
+
812
+ // npm create <pkg> / npm init <pkg> → npx create-<pkg>
813
+ if (sub === "create" || (sub === "init" && positional.length > 0)) {
814
+ const initializer = positional[0];
815
+ let pkgSpec: string;
816
+ if (initializer.startsWith("@")) {
817
+ // scoped: npm create @scope/pkg → npx @scope/create-pkg
818
+ pkgSpec = initializer;
819
+ } else {
820
+ // vite@latest → create-vite@latest
821
+ const atIdx = initializer.indexOf("@");
822
+ if (atIdx > 0) {
823
+ const name = initializer.slice(0, atIdx);
824
+ const ver = initializer.slice(atIdx);
825
+ pkgSpec = `create-${name}${ver}`;
826
+ } else {
827
+ pkgSpec = `create-${initializer}`;
828
+ }
829
+ }
830
+ return npxExecute(["-y", pkgSpec, ...positional.slice(1), ...flags], ctx);
831
+ }
832
+
833
+ // plain npm init [-y] → create package.json
834
+ const p = `${ctx.cwd}/package.json`.replace(/\/+/g, "/");
835
+ if (_vol!.existsSync(p)) {
836
+ return {
837
+ stdout: "",
838
+ stderr: formatWarn("package.json already exists", "npm"),
839
+ exitCode: 0,
840
+ };
841
+ }
842
+
843
+ const isYes = flags.includes("-y") || flags.includes("--yes");
844
+ const name = ctx.cwd.split("/").filter(Boolean).pop() || "my-project";
845
+
846
+ const pkg: PackageManifest = {
847
+ name,
848
+ version: "1.0.0",
849
+ description: "",
850
+ main: "index.js",
851
+ scripts: {
852
+ test: 'echo "Error: no test specified" && exit 1',
853
+ start: "node index.js",
854
+ },
855
+ keywords: [],
856
+ author: "",
857
+ license: "ISC",
858
+ };
859
+
860
+ _vol!.writeFileSync(p, JSON.stringify(pkg, null, 2));
861
+ const out = isYes
862
+ ? `Wrote to ${p}\n`
863
+ : `Wrote to ${p}\n\n${JSON.stringify(pkg, null, 2)}\n`;
864
+ return { stdout: out, stderr: "", exitCode: 0 };
865
+ }
866
+
867
+ async function npmInfo(
868
+ args: string[],
869
+ ctx: ShellContext,
870
+ ): Promise<ShellResult> {
871
+ const name = args[0];
872
+ if (!name)
873
+ return {
874
+ stdout: "",
875
+ stderr: formatErr("Usage: npm info <package>", "npm"),
876
+ exitCode: 1,
877
+ };
878
+
879
+ const pkgJsonPath = `/node_modules/${name}/package.json`;
880
+ if (_vol!.existsSync(pkgJsonPath)) {
881
+ try {
882
+ const pkg = JSON.parse(
883
+ _vol!.readFileSync(pkgJsonPath, "utf8"),
884
+ ) as PackageManifest;
885
+ let out = `${pkg.name}@${pkg.version}\n`;
886
+ if (pkg.description) out += `${pkg.description}\n`;
887
+ if (pkg.license) out += `license: ${pkg.license}\n`;
888
+ if (pkg.homepage) out += `homepage: ${pkg.homepage}\n`;
889
+ if (pkg.dependencies) {
890
+ out += "\ndependencies:\n";
891
+ for (const [k, v] of Object.entries(pkg.dependencies))
892
+ out += ` ${k}: ${v}\n`;
893
+ }
894
+ return { stdout: out, stderr: "", exitCode: 0 };
895
+ } catch {
896
+ /* fallthrough */
897
+ }
898
+ }
899
+
900
+ // fall back to registry
901
+ try {
902
+ const { RegistryClient } = await import("../packages/registry-client");
903
+ const client = new RegistryClient();
904
+ const meta = await client.fetchManifest(name);
905
+ const latest = meta["dist-tags"]?.latest;
906
+ let out = `${name}@${latest ?? "unknown"}\n`;
907
+ if (latest && meta.versions[latest]) {
908
+ const ver = meta.versions[latest] as unknown as Record<string, unknown>;
909
+ if (ver.description) out += `${ver.description}\n`;
910
+ if (ver.license) out += `license: ${ver.license}\n`;
911
+ if (ver.homepage) out += `homepage: ${ver.homepage}\n`;
912
+ }
913
+ return { stdout: out, stderr: "", exitCode: 0 };
914
+ } catch (e) {
915
+ return {
916
+ stdout: "",
917
+ stderr: formatErr(`Not found: ${name}`, "npm"),
918
+ exitCode: 1,
919
+ };
920
+ }
921
+ }
922
+
923
+ function npmPack(ctx: ShellContext): ShellResult {
924
+ const r = loadManifest(ctx.cwd);
925
+ if ("fail" in r) return r.fail;
926
+
927
+ const notice = `${A_DIM}npm notice${A_RESET}`;
928
+ let out = `${notice}\n`;
929
+ out += `${notice} ${A_BOLD}package:${A_RESET} ${r.pkg.name}@${r.pkg.version}\n`;
930
+
931
+ const files: string[] = [];
932
+ const walk = (dir: string) => {
933
+ try {
934
+ for (const name of _vol!.readdirSync(dir)) {
935
+ if (name === "node_modules" || name.startsWith(".")) continue;
936
+ const full = `${dir}/${name}`;
937
+ const st = _vol!.statSync(full);
938
+ if (st.isDirectory()) walk(full);
939
+ else files.push(full);
940
+ }
941
+ } catch {
942
+ /* */
943
+ }
944
+ };
945
+ walk(ctx.cwd);
946
+
947
+ for (const f of files) out += `${notice} ${f}\n`;
948
+ out += `${notice} ${A_BOLD}total files:${A_RESET} ${files.length}\n`;
949
+ return { stdout: out, stderr: "", exitCode: 0 };
950
+ }
951
+
952
+ function npmConfig(args: string[], ctx: ShellContext): ShellResult {
953
+ const sub = args[0];
954
+ if (!sub || sub === "list") {
955
+ let out = "; nodepod project config\n";
956
+ out += `prefix = "${ctx.cwd}"\n`;
957
+ out += `registry = "${NPM_REGISTRY_URL_SLASH}"\n`;
958
+ return { stdout: out, stderr: "", exitCode: 0 };
959
+ }
960
+ if (sub === "get") {
961
+ const key = args[1];
962
+ if (key === "prefix")
963
+ return { stdout: ctx.cwd + "\n", stderr: "", exitCode: 0 };
964
+ if (key === "registry")
965
+ return {
966
+ stdout: NPM_REGISTRY_URL_SLASH + "\n",
967
+ stderr: "",
968
+ exitCode: 0,
969
+ };
970
+ return { stdout: "undefined\n", stderr: "", exitCode: 0 };
971
+ }
972
+ if (sub === "set") {
973
+ return {
974
+ stdout: "",
975
+ stderr: formatWarn("config set: not supported in nodepod", "npm"),
976
+ exitCode: 0,
977
+ };
978
+ }
979
+ return {
980
+ stdout: "",
981
+ stderr: formatErr(`config: unknown subcommand "${sub}"`, "npm"),
982
+ exitCode: 1,
983
+ };
984
+ }
985
+
986
+ // Direct node binary execution (shared by node command & npx)
987
+
988
+ export async function executeNodeBinary(
989
+ filePath: string,
990
+ args: string[],
991
+ ctx: ShellContext,
992
+ opts?: {
993
+ isFork?: boolean;
994
+ workerThreadsOverride?: {
995
+ isMainThread: boolean;
996
+ parentPort: unknown;
997
+ workerData: unknown;
998
+ threadId: number;
999
+ };
1000
+ },
1001
+ ): Promise<ShellResult> {
1002
+ if (!_vol) return { stdout: "", stderr: "Volume unavailable\n", exitCode: 1 };
1003
+
1004
+ const rawPath = filePath.startsWith("/")
1005
+ ? filePath
1006
+ : `${ctx.cwd}/${filePath}`.replace(/\/+/g, "/");
1007
+
1008
+ // resolve entry file: exact path, then extensions, then directory index
1009
+ let resolved = "";
1010
+ if (_vol.existsSync(rawPath) && !_vol.statSync(rawPath).isDirectory()) {
1011
+ resolved = rawPath;
1012
+ } else {
1013
+ const exts = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
1014
+ for (const ext of exts) {
1015
+ if (_vol.existsSync(rawPath + ext)) { resolved = rawPath + ext; break; }
1016
+ }
1017
+ if (!resolved) {
1018
+ // try as directory with index file
1019
+ const dirPath = rawPath.endsWith("/") ? rawPath : rawPath + "/";
1020
+ for (const idx of ["index.js", "index.mjs", "index.ts", "index.cjs"]) {
1021
+ if (_vol.existsSync(dirPath + idx)) { resolved = dirPath + idx; break; }
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ if (!resolved) {
1027
+ const errMsg = `Cannot locate module '${rawPath}'\n`;
1028
+ const errSink = getStderrSink();
1029
+ if (errSink) errSink(errMsg);
1030
+ return {
1031
+ stdout: "",
1032
+ stderr: errMsg,
1033
+ exitCode: 1,
1034
+ };
1035
+ }
1036
+
1037
+ let out = "";
1038
+ let err = "";
1039
+ let didExit = false;
1040
+ let code = 0;
1041
+
1042
+ const pushOut = (s: string): boolean => {
1043
+ out += s;
1044
+ const sink = getStdoutSink();
1045
+ if (sink) sink(s);
1046
+ return true;
1047
+ };
1048
+ const pushErr = (s: string): boolean => {
1049
+ err += s;
1050
+ const sink = getStderrSink();
1051
+ if (sink) sink(s);
1052
+ return true;
1053
+ };
1054
+
1055
+ // ScriptEngine's module wrapper overwrites globalThis.process -- save and restore
1056
+ const savedProcess = (globalThis as any).process;
1057
+
1058
+ const sandbox = new ScriptEngine(_vol, {
1059
+ cwd: ctx.cwd,
1060
+ env: ctx.env,
1061
+ onConsole: (m: string, cArgs: unknown[]) => {
1062
+ // filter out process.exit sentinel errors logged by library code
1063
+ if (cArgs.length === 1) {
1064
+ const a = cArgs[0];
1065
+ if (a instanceof Error && a.message.startsWith("Process exited with code")) return;
1066
+ if (typeof a === "string" && a.startsWith("Error: Process exited with code")) return;
1067
+ }
1068
+ // error/warn → stderr, everything else → stdout
1069
+ const line = utilFormat(cArgs[0], ...cArgs.slice(1)) + "\n";
1070
+ m === "error" ? pushErr(line) : pushOut(line);
1071
+ },
1072
+ onStdout: pushOut,
1073
+ onStderr: pushErr,
1074
+ workerThreadsOverride: opts?.workerThreadsOverride,
1075
+ });
1076
+
1077
+ const proc = sandbox.getProcess();
1078
+
1079
+ // sync shell cwd when process.chdir() is called (create-next-app etc. depend on this)
1080
+ proc._chdirHook = (dir: string) => {
1081
+ if (_shell) _shell.setCwd(dir);
1082
+ };
1083
+
1084
+ proc.exit = ((c = 0) => {
1085
+ // suppress exit when dev servers are active (SES/error handlers call exit(1) but we want to keep serving)
1086
+ if (getAllServers().size > 0 && c !== 0) {
1087
+ // process.exit suppressed — servers still active
1088
+ return;
1089
+ }
1090
+ if (!didExit) {
1091
+ didExit = true;
1092
+ code = c;
1093
+ proc.emit("exit", c);
1094
+ }
1095
+ // Always throw to halt execution — mirrors real Node.js process.exit()
1096
+ // which terminates immediately. The TLA .catch() and try/catch both
1097
+ // handle "Process exited with code" errors.
1098
+ throw new Error(`Process exited with code ${c}`);
1099
+ }) as (c?: number) => never;
1100
+
1101
+ proc.argv = ["node", resolved, ...args];
1102
+
1103
+ // wire IPC for forked children
1104
+ if (_ipcSendFn) {
1105
+ proc.send = ((msg: unknown, _cb?: (e: Error | null) => void): boolean => {
1106
+ if (_ipcSendFn) {
1107
+ _ipcSendFn(msg);
1108
+ if (typeof _cb === "function") _cb(null);
1109
+ return true;
1110
+ }
1111
+ return false;
1112
+ }) as any;
1113
+ proc.connected = true;
1114
+ proc.disconnect = (() => {
1115
+ proc.connected = false;
1116
+ }) as () => void;
1117
+
1118
+ // incoming IPC from parent → emit on process (also replays queued messages)
1119
+ setIPCReceiveHandler((data: unknown) => {
1120
+ proc.emit("message", data);
1121
+ });
1122
+ }
1123
+
1124
+ const prevLiveStdin = _liveStdin;
1125
+ // capture locally -- the module-level _haltSignal gets cleared by clearStreamingCallbacks()
1126
+ // while this ENB's wait loop may still be running
1127
+ const myHaltSignal = getHaltSignal();
1128
+ if (myHaltSignal) {
1129
+ proc.stdout.isTTY = true;
1130
+ proc.stderr.isTTY = true;
1131
+ proc.stdin.isTTY = true;
1132
+ // sync terminal dimensions for TUI libraries
1133
+ const cols = getTermCols();
1134
+ const rows = getTermRows();
1135
+ proc.stdout.columns = cols;
1136
+ proc.stdout.rows = rows;
1137
+ proc.stderr.columns = cols;
1138
+ proc.stderr.rows = rows;
1139
+ proc.stdin.setRawMode = (flag: boolean) => {
1140
+ proc.stdin.isRaw = flag;
1141
+ // notify terminal so it switches echo mode
1142
+ if (_rawModeChangeCb) _rawModeChangeCb(flag);
1143
+ return proc.stdin;
1144
+ };
1145
+ _liveStdin = proc.stdin;
1146
+ // also update context's liveStdin
1147
+ const ctx = getActiveContext();
1148
+ if (ctx) ctx.liveStdin = proc.stdin;
1149
+ }
1150
+
1151
+ // for forked children: ref() to simulate the IPC channel handle.
1152
+ // real Node.js keeps the IPC channel ref'd until process.disconnect().
1153
+ // we hold a ref for the entire fork lifetime, released on disconnect or exit.
1154
+ const isFork = !!opts?.isFork;
1155
+ if (isFork) {
1156
+ ref();
1157
+ // disconnect → unref (mirrors Node.js IPC channel.unref())
1158
+ const origDisconnect = proc.disconnect;
1159
+ proc.disconnect = (() => {
1160
+ origDisconnect?.call(proc);
1161
+ unref();
1162
+ }) as () => void;
1163
+ }
1164
+
1165
+ let scriptError: Error | null = null;
1166
+ let tlaSettled = false;
1167
+
1168
+ try {
1169
+ const tlaPromise = sandbox.runFileTLA(resolved);
1170
+ tlaPromise
1171
+ .catch((e) => {
1172
+ if (
1173
+ e instanceof Error &&
1174
+ e.message.startsWith("Process exited with code")
1175
+ ) {
1176
+ return;
1177
+ }
1178
+ const msg = formatThrown(e);
1179
+ pushErr(`Error: ${msg}\n`);
1180
+ if (!didExit) {
1181
+ didExit = true;
1182
+ code = 1;
1183
+ }
1184
+ })
1185
+ .finally(() => {
1186
+ tlaSettled = true;
1187
+ });
1188
+ } catch (e) {
1189
+ if (
1190
+ e instanceof Error &&
1191
+ e.message.startsWith("Process exited with code")
1192
+ ) {
1193
+ // process.exit() — handled by didExit flag
1194
+ } else {
1195
+ const msg = formatThrown(e);
1196
+ scriptError = e instanceof Error ? e : new Error(msg);
1197
+ }
1198
+ }
1199
+
1200
+ const cleanup = () => {
1201
+ if (savedProcess) (globalThis as any).process = savedProcess;
1202
+ };
1203
+
1204
+ if (scriptError) {
1205
+ cleanup();
1206
+ const errMsg = scriptError.message || scriptError.name || "Unknown error";
1207
+ const errStack = scriptError.stack || "";
1208
+ const fullMsg =
1209
+ errStack && !errStack.includes(errMsg)
1210
+ ? `${errMsg}\n${errStack}`
1211
+ : errStack || errMsg;
1212
+ return { stdout: out, stderr: err + `Error: ${fullMsg}\n`, exitCode: 1 };
1213
+ }
1214
+
1215
+ // process.exit() called synchronously -- bail
1216
+ if (didExit) {
1217
+ cleanup();
1218
+ return { stdout: out, stderr: err, exitCode: code };
1219
+ }
1220
+
1221
+ // yield one tick so microtasks settle
1222
+ await new Promise((r) => setTimeout(r, 0));
1223
+
1224
+ // keep the process alive while TLA hasn't settled, ref handles exist,
1225
+ // or HTTP servers are registered. process.exit() and Ctrl+C break immediately.
1226
+
1227
+ const shouldStayAlive = (): boolean => {
1228
+ if (!tlaSettled) return true;
1229
+ if (getRefCount() > 0) return true;
1230
+ if (getAllServers().size > 0) return true;
1231
+ return false;
1232
+ };
1233
+
1234
+ // fast path: nothing keeping the process alive
1235
+ if (!myHaltSignal && !shouldStayAlive()) {
1236
+ cleanup();
1237
+ return { stdout: out, stderr: err, exitCode: 0 };
1238
+ }
1239
+
1240
+ // avoid duplicate output when same error fires as both 'error' and 'unhandledrejection'
1241
+ const handledErrors = new WeakSet<object>();
1242
+
1243
+ const rejHandler = (ev: PromiseRejectionEvent) => {
1244
+ ev.preventDefault();
1245
+ const r = ev.reason;
1246
+ if (
1247
+ r instanceof Error &&
1248
+ r.message.startsWith("Process exited with code")
1249
+ ) {
1250
+ return;
1251
+ }
1252
+ // mark as handled to prevent errHandler double-logging
1253
+ if (r != null && typeof r === "object") handledErrors.add(r);
1254
+ // emit 'unhandledRejection' on process -- if a handler exists, it handles it
1255
+ try {
1256
+ const hasHandler = proc.listenerCount
1257
+ ? proc.listenerCount("unhandledRejection") > 0
1258
+ : false;
1259
+ proc.emit("unhandledRejection", r, ev.promise);
1260
+ if (hasHandler) return; // Handler dealt with it — don't log
1261
+ } catch { /* ignore handler errors */ }
1262
+ const rejMsg = r instanceof Error
1263
+ ? `Unhandled rejection: ${r.message}\n${r.stack ?? ""}\n`
1264
+ : `Unhandled rejection: ${String(r)}\n`;
1265
+ pushErr(rejMsg);
1266
+ };
1267
+ const errHandler = (ev: ErrorEvent) => {
1268
+ ev.preventDefault();
1269
+ const e = ev.error ?? new Error(ev.message || "Unknown error");
1270
+ // skip if already handled by rejHandler (same error fires on both global events)
1271
+ if (e != null && typeof e === "object" && handledErrors.has(e)) return;
1272
+ if (e != null && typeof e === "object") handledErrors.add(e);
1273
+ // emit 'uncaughtException' -- frameworks like webpack register handlers for graceful recovery
1274
+ try {
1275
+ const hasUncaught = proc.listenerCount
1276
+ ? proc.listenerCount("uncaughtException") > 0
1277
+ : false;
1278
+ proc.emit("uncaughtException", e);
1279
+ if (hasUncaught) return; // Handler dealt with it — don't log or crash
1280
+ } catch { /* handler threw — fall through to default logging */ }
1281
+ // if there's an unhandledRejection listener, it'll handle this -- don't double-log
1282
+ try {
1283
+ const hasRej = proc.listenerCount
1284
+ ? proc.listenerCount("unhandledRejection") > 0
1285
+ : false;
1286
+ if (hasRej) return;
1287
+ } catch { /* ignore */ }
1288
+ const msg = e instanceof Error
1289
+ ? `${e.stack || e.message}\n`
1290
+ : `Uncaught: ${String(e)}\n`;
1291
+ pushErr(msg);
1292
+ };
1293
+ globalThis.addEventListener("unhandledrejection", rejHandler);
1294
+ globalThis.addEventListener("error", errHandler);
1295
+
1296
+ try {
1297
+ // resolves when Ctrl+C / signal fires
1298
+ const haltPromise = myHaltSignal
1299
+ ? new Promise<void>((r) => {
1300
+ if (myHaltSignal!.aborted) { r(); return; }
1301
+ myHaltSignal!.addEventListener("abort", () => r(), { once: true });
1302
+ })
1303
+ : null;
1304
+
1305
+ // give async startup code time to register handles before deciding the process is done
1306
+ let consecutiveEmpty = 0;
1307
+ let everNonEmpty = false;
1308
+
1309
+ while (!didExit) {
1310
+ if (myHaltSignal?.aborted) {
1311
+ break;
1312
+ }
1313
+
1314
+ // wake on drain notification or periodic tick
1315
+ let wakeResolve!: () => void;
1316
+ const wakePromise = new Promise<void>((r) => { wakeResolve = r; });
1317
+ const removeDrain = addDrainListener(wakeResolve);
1318
+
1319
+ const tickMs = (!everNonEmpty && myHaltSignal && !out && !err)
1320
+ ? TIMEOUTS.WAIT_LOOP_TICK
1321
+ : 50;
1322
+ const racers: Promise<unknown>[] = [
1323
+ wakePromise,
1324
+ new Promise<void>((r) => setTimeout(r, tickMs)),
1325
+ ];
1326
+ if (haltPromise) racers.push(haltPromise);
1327
+
1328
+ await Promise.race(racers);
1329
+ removeDrain();
1330
+
1331
+ if (myHaltSignal?.aborted) {
1332
+ break;
1333
+ }
1334
+ if (didExit) {
1335
+ break;
1336
+ }
1337
+
1338
+ if (!shouldStayAlive()) {
1339
+ // yield one microtask turn for async transitions (e.g. @clack closing/reopening readline)
1340
+ await new Promise<void>((r) => queueMicrotask(r));
1341
+ if (didExit || myHaltSignal?.aborted) break;
1342
+ if (shouldStayAlive()) {
1343
+ everNonEmpty = true;
1344
+ consecutiveEmpty = 0;
1345
+ continue;
1346
+ }
1347
+
1348
+ consecutiveEmpty++;
1349
+
1350
+ if (myHaltSignal) {
1351
+ // terminal mode: tiered timeout -- no output yet (10s), had refs before (5s), output but no refs (2s)
1352
+ if (!everNonEmpty && !out && !err) {
1353
+ if (consecutiveEmpty >= 50) {
1354
+ break;
1355
+ }
1356
+ } else if (!everNonEmpty) {
1357
+ if (consecutiveEmpty >= Math.ceil(2_000 / tickMs)) {
1358
+ break;
1359
+ }
1360
+ } else {
1361
+ if (consecutiveEmpty >= 100) {
1362
+ break;
1363
+ }
1364
+ }
1365
+ } else {
1366
+ break; // Non-terminal: exit immediately when empty.
1367
+ }
1368
+ } else {
1369
+ consecutiveEmpty = 0;
1370
+ everNonEmpty = true;
1371
+ }
1372
+ }
1373
+
1374
+ return { stdout: out, stderr: err, exitCode: didExit ? code : 0 };
1375
+ } finally {
1376
+ cleanup();
1377
+ // defuse proc.exit so floating Promises don't throw unhandled rejections
1378
+ proc.exit = (() => {}) as unknown as (c?: number) => never;
1379
+ globalThis.removeEventListener("unhandledrejection", rejHandler);
1380
+ globalThis.removeEventListener("error", errHandler);
1381
+ // restore _liveStdin for the parent's stdin relay
1382
+ _liveStdin = prevLiveStdin;
1383
+ const ctxRestore = getActiveContext();
1384
+ if (ctxRestore) ctxRestore.liveStdin = prevLiveStdin;
1385
+ // full reset
1386
+ closeAllServers();
1387
+ resetRefCount();
1388
+ resetActiveInterfaceCount();
1389
+ }
1390
+ }
1391
+
1392
+ async function npxExecute(
1393
+ params: string[],
1394
+ ctx: ShellContext,
1395
+ ): Promise<ShellResult> {
1396
+ if (!_vol) return { stdout: "", stderr: "Volume unavailable\n", exitCode: 1 };
1397
+
1398
+ // parse npx flags
1399
+ let autoInstall = true;
1400
+ let installPkg: string | null = null;
1401
+ const filteredParams: string[] = [];
1402
+ let separatorSeen = false;
1403
+
1404
+ for (let i = 0; i < params.length; i++) {
1405
+ if (separatorSeen) {
1406
+ filteredParams.push(params[i]);
1407
+ continue;
1408
+ }
1409
+ if (params[i] === "--") {
1410
+ separatorSeen = true;
1411
+ continue;
1412
+ }
1413
+ if (params[i] === "-y" || params[i] === "--yes") {
1414
+ autoInstall = true;
1415
+ continue;
1416
+ }
1417
+ if (params[i] === "-n" || params[i] === "--no") {
1418
+ autoInstall = false;
1419
+ continue;
1420
+ }
1421
+ if (
1422
+ (params[i] === "-p" || params[i] === "--package") &&
1423
+ i + 1 < params.length
1424
+ ) {
1425
+ installPkg = params[++i];
1426
+ continue;
1427
+ }
1428
+ if (params[i] === "--help" || params[i] === "-h") {
1429
+ return {
1430
+ stdout:
1431
+ `${A_BOLD}Usage:${A_RESET} npx [options] <command> [args...]\n\n` +
1432
+ `${A_BOLD}Options:${A_RESET}\n` +
1433
+ ` ${A_CYAN}-y${A_RESET}, ${A_CYAN}--yes${A_RESET} Auto-confirm install\n` +
1434
+ ` ${A_CYAN}-n${A_RESET}, ${A_CYAN}--no${A_RESET} Don't install if not found\n` +
1435
+ ` ${A_CYAN}-p${A_RESET}, ${A_CYAN}--package${A_RESET} Specify package to install\n` +
1436
+ ` ${A_CYAN}--${A_RESET} Separator for command args\n`,
1437
+ stderr: "",
1438
+ exitCode: 0,
1439
+ };
1440
+ }
1441
+ filteredParams.push(params[i]);
1442
+ }
1443
+
1444
+ let pkgSpec = filteredParams[0];
1445
+ if (!pkgSpec) {
1446
+ return {
1447
+ stdout: "",
1448
+ stderr: formatErr("missing command", "npm"),
1449
+ exitCode: 1,
1450
+ };
1451
+ }
1452
+
1453
+ let cmdName: string;
1454
+ let version: string | undefined;
1455
+ if (pkgSpec.startsWith("@")) {
1456
+ // scoped: @scope/name or @scope/name@version
1457
+ const rest = pkgSpec.slice(1);
1458
+ const atIdx = rest.indexOf("@");
1459
+ if (atIdx > 0 && rest.indexOf("/") < atIdx) {
1460
+ cmdName = "@" + rest.slice(0, atIdx);
1461
+ version = rest.slice(atIdx + 1);
1462
+ } else {
1463
+ cmdName = pkgSpec;
1464
+ }
1465
+ } else {
1466
+ const atIdx = pkgSpec.indexOf("@");
1467
+ if (atIdx > 0) {
1468
+ cmdName = pkgSpec.slice(0, atIdx);
1469
+ version = pkgSpec.slice(atIdx + 1);
1470
+ } else {
1471
+ cmdName = pkgSpec;
1472
+ }
1473
+ }
1474
+
1475
+ // -p overrides the package to install
1476
+ const actualPkg = installPkg || pkgSpec;
1477
+ const actualPkgName = installPkg
1478
+ ? installPkg.replace(/@[^@/]+$/, "").replace(/^@/, "")
1479
+ : cmdName;
1480
+
1481
+ let resolvedBin = findBinary(cmdName, _vol, ctx.cwd);
1482
+
1483
+ // not found locally -- try installing
1484
+ if (!resolvedBin && autoInstall) {
1485
+ const installResult = await installPackages([actualPkg], ctx);
1486
+ if (installResult.exitCode !== 0) return installResult;
1487
+ resolvedBin = findBinary(cmdName, _vol, ctx.cwd);
1488
+ }
1489
+
1490
+ if (!resolvedBin) {
1491
+ return {
1492
+ stdout: "",
1493
+ stderr: `npx: command '${cmdName}' not found\n`,
1494
+ exitCode: 1,
1495
+ };
1496
+ }
1497
+
1498
+ // run directly via node handler to avoid shell re-parsing mangling arguments
1499
+ return executeNodeBinary(resolvedBin, filteredParams.slice(1), ctx);
1500
+ }
1501
+
1502
+ function findBinary(
1503
+ name: string,
1504
+ vol: MemoryVolume,
1505
+ cwd?: string,
1506
+ ): string | null {
1507
+ const cleanName = name.startsWith("@") ? name : name;
1508
+ const shortName = cleanName.includes("/")
1509
+ ? cleanName.split("/").pop()!
1510
+ : cleanName;
1511
+
1512
+ // cwd-local first, then root fallback
1513
+ const searchRoots =
1514
+ cwd && cwd !== "/"
1515
+ ? [`${cwd}/node_modules`, `/node_modules`]
1516
+ : [`/node_modules`];
1517
+
1518
+ for (const nmDir of searchRoots) {
1519
+ // check package.json bin field for the real JS entry point
1520
+ const pkgJsonPath = `${nmDir}/${cleanName}/package.json`;
1521
+ if (vol.existsSync(pkgJsonPath)) {
1522
+ try {
1523
+ const pkg = JSON.parse(
1524
+ vol.readFileSync(pkgJsonPath, "utf8") as string,
1525
+ ) as PackageManifest;
1526
+ if (pkg.bin) {
1527
+ if (typeof pkg.bin === "string") {
1528
+ return `${nmDir}/${cleanName}/${pkg.bin}`;
1529
+ }
1530
+ if (typeof pkg.bin === "object") {
1531
+ const binMap = pkg.bin as Record<string, string>;
1532
+ const binEntry =
1533
+ binMap[shortName] ||
1534
+ binMap[cleanName] ||
1535
+ Object.values(binMap)[0];
1536
+ if (binEntry) return `${nmDir}/${cleanName}/${binEntry}`;
1537
+ }
1538
+ }
1539
+ // fallback to main
1540
+ if (pkg.main) return `${nmDir}/${cleanName}/${pkg.main}`;
1541
+ } catch {
1542
+ /* ignore */
1543
+ }
1544
+ }
1545
+
1546
+ // .bin stubs -- resolve through to the actual JS target they reference
1547
+ const binPath = `${nmDir}/.bin/${name}`;
1548
+ if (vol.existsSync(binPath)) {
1549
+ try {
1550
+ const stub = vol.readFileSync(binPath, "utf8");
1551
+ // stubs look like: node "/node_modules/pkg/index.js" "$@"
1552
+ const match = stub.match(/node\s+"([^"]+)"/);
1553
+ if (match && vol.existsSync(match[1])) return match[1];
1554
+ } catch {
1555
+ /* ignore */
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ return null;
1561
+ }
1562
+
1563
+ export interface RunOptions {
1564
+ cwd?: string;
1565
+ env?: Record<string, string>;
1566
+ encoding?: BufferEncoding | "buffer";
1567
+ timeout?: number;
1568
+ maxBuffer?: number;
1569
+ shell?: string | boolean;
1570
+ }
1571
+
1572
+ export type RunCallback = (
1573
+ err: Error | null,
1574
+ stdout: string | Buffer,
1575
+ stderr: string | Buffer,
1576
+ ) => void;
1577
+
1578
+ export interface SpawnConfig {
1579
+ cwd?: string;
1580
+ env?: Record<string, string>;
1581
+ shell?: boolean | string;
1582
+ stdio?: "pipe" | "inherit" | "ignore" | Array<"pipe" | "inherit" | "ignore">;
1583
+ }
1584
+
1585
+ export function exec(
1586
+ command: string,
1587
+ optsOrCb?: RunOptions | RunCallback,
1588
+ cb?: RunCallback,
1589
+ ): ShellProcess {
1590
+ let options: RunOptions = {};
1591
+ let done: RunCallback | undefined;
1592
+ if (typeof optsOrCb === "function") {
1593
+ done = optsOrCb;
1594
+ } else if (optsOrCb) {
1595
+ options = optsOrCb;
1596
+ done = cb;
1597
+ }
1598
+
1599
+ const child = new ShellProcess();
1600
+
1601
+ if (!_shell) {
1602
+ const e = new Error("[Nodepod] exec requires shell. Call initShellExec() first.");
1603
+ setTimeout(() => {
1604
+ child.emit("error", e);
1605
+ if (done) done(e, "", "");
1606
+ }, 0);
1607
+ return child;
1608
+ }
1609
+
1610
+ const cwd = options.cwd ?? getShellCwd();
1611
+ const env = (options.env as Record<string, string>) ?? undefined;
1612
+
1613
+ // run inline via NodepodShell -- only fork() gets a dedicated worker
1614
+ _shell.exec(command, { cwd, env }).then(
1615
+ (result) => {
1616
+ const { stdout, stderr, exitCode } = result;
1617
+ if (stdout) child.stdout?.push(Buffer.from(stdout));
1618
+ if (stderr) child.stderr?.push(Buffer.from(stderr));
1619
+ child.stdout?.push(null);
1620
+ child.stderr?.push(null);
1621
+ child.exitCode = exitCode;
1622
+ child.emit("close", exitCode, null);
1623
+ child.emit("exit", exitCode, null);
1624
+ if (done) {
1625
+ if (exitCode !== 0) {
1626
+ const e = new Error(`Command failed: ${command}`);
1627
+ (e as any).code = exitCode;
1628
+ done(e, stdout ?? "", stderr ?? "");
1629
+ } else {
1630
+ done(null, stdout ?? "", stderr ?? "");
1631
+ }
1632
+ }
1633
+ },
1634
+ (e) => {
1635
+ child.emit("error", e instanceof Error ? e : new Error(String(e)));
1636
+ if (done) done(e instanceof Error ? e : new Error(String(e)), "", "");
1637
+ },
1638
+ );
1639
+
1640
+ return child;
1641
+ }
1642
+
1643
+ export function execSync(cmd: string, opts?: RunOptions): string | Buffer {
1644
+ const trimmed = cmd.trim();
1645
+ const encoding = opts?.encoding;
1646
+
1647
+ // fast path: trivially synchronous commands (version checks, echo, pwd)
1648
+ const result = handleSyncCommand(trimmed, opts);
1649
+ if (result !== null) {
1650
+ if (encoding === "buffer") return Buffer.from(result);
1651
+ return result;
1652
+ }
1653
+
1654
+ // true blocking path via Atomics.wait()
1655
+ if (!_syncChannel) {
1656
+ throw new Error(
1657
+ "[Nodepod] execSync requires SyncChannel (worker mode with SharedArrayBuffer). " +
1658
+ "Ensure Nodepod is running in worker mode with COOP/COEP headers.",
1659
+ );
1660
+ }
1661
+
1662
+ const slot = _syncChannel.allocateSlot();
1663
+ const cwd = opts?.cwd ?? (globalThis as any).process?.cwd?.() ?? "/";
1664
+ const env = (opts?.env as Record<string, string>) ?? {};
1665
+
1666
+ (self as any).postMessage({
1667
+ type: "spawn-sync",
1668
+ requestId: _nextSyncRequestId++,
1669
+ command: trimmed.split(/\s+/)[0],
1670
+ args: trimmed.split(/\s+/).slice(1),
1671
+ cwd,
1672
+ env,
1673
+ syncSlot: slot,
1674
+ shellCommand: trimmed,
1675
+ });
1676
+
1677
+ // blocks until main thread spawns child and child completes
1678
+ const { exitCode, stdout } = _syncChannel.waitForResult(slot, 120_000);
1679
+
1680
+ if (exitCode !== 0) {
1681
+ const err: any = new Error(`Command failed: ${trimmed}\n${stdout}`);
1682
+ err.status = exitCode;
1683
+ err.stderr = Buffer.from("");
1684
+ err.stdout = Buffer.from(stdout);
1685
+ err.output = [null, err.stdout, err.stderr];
1686
+ throw err;
1687
+ }
1688
+
1689
+ if (encoding === "buffer") return Buffer.from(stdout);
1690
+ return stdout;
1691
+ }
1692
+
1693
+ let _nextSyncRequestId = 1;
1694
+
1695
+ const KNOWN_BINS: Record<string, string> = {
1696
+ node: "/usr/local/bin/node",
1697
+ npm: "/usr/local/bin/npm",
1698
+ npx: "/usr/local/bin/npx",
1699
+ pnpm: "/usr/local/bin/pnpm",
1700
+ yarn: "/usr/local/bin/yarn",
1701
+ bun: "/usr/local/bin/bun",
1702
+ bunx: "/usr/local/bin/bunx",
1703
+ git: "/usr/bin/git",
1704
+ };
1705
+
1706
+ function isBinaryAvailable(name: string): string | null {
1707
+ if (KNOWN_BINS[name]) return KNOWN_BINS[name];
1708
+ if (_vol) {
1709
+ const binPath = `/node_modules/.bin/${name}`;
1710
+ if (_vol.existsSync(binPath)) return binPath;
1711
+ }
1712
+ return null;
1713
+ }
1714
+
1715
+ // throw an error matching real Node.js execSync behaviour for failed commands
1716
+ function throwCommandNotFound(cmd: string): never {
1717
+ const err: any = new Error(
1718
+ `Command failed: ${cmd}\n/bin/sh: 1: ${cmd.split(/\s+/)[0]}: not found\n`,
1719
+ );
1720
+ err.status = 127;
1721
+ err.stderr = Buffer.from(`/bin/sh: 1: ${cmd.split(/\s+/)[0]}: not found\n`);
1722
+ err.stdout = Buffer.from("");
1723
+ throw err;
1724
+ }
1725
+
1726
+ function _findGitDir(cwd: string): { gitDir: string; workDir: string } | null {
1727
+ if (!_vol) return null;
1728
+ let dir = cwd;
1729
+ while (true) {
1730
+ const gitPath = dir + "/.git";
1731
+ try { if (_vol.existsSync(gitPath)) return { gitDir: gitPath, workDir: dir }; } catch { /* */ }
1732
+ const parent = dir.substring(0, dir.lastIndexOf("/")) || "/";
1733
+ if (parent === dir) break;
1734
+ dir = parent;
1735
+ }
1736
+ try { if (_vol.existsSync("/.git")) return { gitDir: "/.git", workDir: "/" }; } catch { /* */ }
1737
+ return null;
1738
+ }
1739
+
1740
+ function _readHeadBranch(gitDir: string): string {
1741
+ try {
1742
+ const head = (_vol!.readFileSync(gitDir + "/HEAD", "utf8" as any) as string).trim();
1743
+ if (head.startsWith("ref: refs/heads/")) return head.slice(16);
1744
+ return head.slice(0, 7);
1745
+ } catch { return "main"; }
1746
+ }
1747
+
1748
+ function _resolveHeadHash(gitDir: string): string | null {
1749
+ try {
1750
+ const head = (_vol!.readFileSync(gitDir + "/HEAD", "utf8" as any) as string).trim();
1751
+ if (head.startsWith("ref: ")) {
1752
+ const refPath = gitDir + "/" + head.slice(5);
1753
+ return (_vol!.readFileSync(refPath, "utf8" as any) as string).trim();
1754
+ }
1755
+ return head;
1756
+ } catch { return null; }
1757
+ }
1758
+
1759
+ function _readGitConfigKey(gitDir: string, key: string): string | null {
1760
+ try {
1761
+ const config = _vol!.readFileSync(gitDir + "/config", "utf8" as any) as string;
1762
+ const parts = key.split(".");
1763
+ let sectionName: string, subSection: string | null = null, propName: string;
1764
+ if (parts.length === 3) { sectionName = parts[0]; subSection = parts[1]; propName = parts[2]; }
1765
+ else if (parts.length === 2) { sectionName = parts[0]; propName = parts[1]; }
1766
+ else return null;
1767
+ const lines = config.split("\n");
1768
+ let inSection = false;
1769
+ for (const line of lines) {
1770
+ const trimmed = line.trim();
1771
+ if (trimmed.startsWith("[")) {
1772
+ inSection = subSection
1773
+ ? trimmed === `[${sectionName} "${subSection}"]`
1774
+ : trimmed === `[${sectionName}]`;
1775
+ continue;
1776
+ }
1777
+ if (inSection) {
1778
+ const m = trimmed.match(/^(\w+)\s*=\s*(.*)$/);
1779
+ if (m && m[1] === propName) return m[2].trim();
1780
+ }
1781
+ }
1782
+ } catch { /* */ }
1783
+ return null;
1784
+ }
1785
+
1786
+ function handleSyncCommand(cmd: string, opts?: RunOptions): string | null {
1787
+ if (/^node\s+(--version|-v)\s*$/.test(cmd)) return VERSIONS.NODE + "\n";
1788
+ if (/^npm\s+(--version|-v)\s*$/.test(cmd)) return VERSIONS.NPM + "\n";
1789
+ if (/^pnpm\s+(--version|-v)\s*$/.test(cmd)) return VERSIONS.PNPM + "\n";
1790
+ if (/^yarn\s+(--version|-v)\s*$/.test(cmd)) return VERSIONS.YARN + "\n";
1791
+ if (/^bun\s+(--version|-v)\s*$/.test(cmd)) return VERSIONS.BUN + "\n";
1792
+
1793
+ // which / command -v
1794
+ const whichMatch = cmd.match(/^(?:which|command\s+-v)\s+(\S+)\s*$/);
1795
+ if (whichMatch) {
1796
+ const binName = whichMatch[1];
1797
+ const binPath = isBinaryAvailable(binName);
1798
+ if (binPath) return binPath + "\n";
1799
+ throwCommandNotFound(cmd);
1800
+ }
1801
+
1802
+ // <binary> --version / -v
1803
+ const versionMatch = cmd.match(/^(\S+)\s+(--version|-v)\s*$/);
1804
+ if (versionMatch) {
1805
+ const binName = versionMatch[1];
1806
+ if (
1807
+ binName === "node" ||
1808
+ binName === "npm" ||
1809
+ binName === "pnpm" ||
1810
+ binName === "yarn" ||
1811
+ binName === "bun"
1812
+ )
1813
+ return null; // handled above
1814
+ if (!isBinaryAvailable(binName)) throwCommandNotFound(cmd);
1815
+ // known binary but no version handler -- fall through to async
1816
+ }
1817
+
1818
+ // registry queries (Next.js uses this to find npm registry)
1819
+ if (/^(?:npm|yarn|pnpm)\s+config\s+get\s+registry\s*$/.test(cmd)) {
1820
+ return NPM_REGISTRY_URL_SLASH.replace(/\/$/, "") + "\n";
1821
+ }
1822
+
1823
+ const echoMatch = cmd.match(/^echo\s+["']?(.*?)["']?\s*$/);
1824
+ if (echoMatch) return echoMatch[1] + "\n";
1825
+ if (/^uname\s+-s\s*$/.test(cmd)) return "Linux\n";
1826
+ if (/^uname\s+-m\s*$/.test(cmd)) return "x86_64\n";
1827
+ if (/^uname\s+-a\s*$/.test(cmd))
1828
+ return "Linux nodepod 5.10.0 #1 SMP x86_64 GNU/Linux\n";
1829
+ // git fast-path for sync commands
1830
+ if (/^git\s+(--version|-v)\s*$/.test(cmd) || cmd === "git --version") {
1831
+ return "git version " + VERSIONS.GIT + "\n";
1832
+ }
1833
+ if (_vol) {
1834
+ const gitRevParseMatch = cmd.match(/^git\s+rev-parse\s+(.+)$/);
1835
+ if (gitRevParseMatch) {
1836
+ const gitArgs = gitRevParseMatch[1].trim();
1837
+ const cwd = opts?.cwd || "/";
1838
+ const gd = _findGitDir(cwd);
1839
+ if (gitArgs === "--show-toplevel") return gd ? gd.workDir + "\n" : "";
1840
+ if (gitArgs === "--is-inside-work-tree") return gd ? "true\n" : "false\n";
1841
+ if (gitArgs === "--git-dir") return gd ? ".git\n" : "";
1842
+ if (gitArgs === "--is-bare-repository") return "false\n";
1843
+ if (gitArgs === "--abbrev-ref HEAD" && gd) return _readHeadBranch(gd.gitDir) + "\n";
1844
+ if ((gitArgs === "HEAD" || gitArgs === "--verify HEAD") && gd) {
1845
+ const h = _resolveHeadHash(gd.gitDir);
1846
+ return h ? h + "\n" : "";
1847
+ }
1848
+ if (gitArgs === "--short HEAD" && gd) {
1849
+ const h = _resolveHeadHash(gd.gitDir);
1850
+ return h ? h.slice(0, 7) + "\n" : "";
1851
+ }
1852
+ }
1853
+ if (/^git\s+branch\s+--show-current\s*$/.test(cmd)) {
1854
+ const cwd = opts?.cwd || "/";
1855
+ const gd = _findGitDir(cwd);
1856
+ if (gd) return _readHeadBranch(gd.gitDir) + "\n";
1857
+ }
1858
+ const gitConfigGetMatch = cmd.match(/^git\s+config\s+(?:--get\s+)?(\S+)\s*$/);
1859
+ if (gitConfigGetMatch) {
1860
+ const cwd = opts?.cwd || "/";
1861
+ const gd = _findGitDir(cwd);
1862
+ if (gd) {
1863
+ const val = _readGitConfigKey(gd.gitDir, gitConfigGetMatch[1]);
1864
+ return val !== null ? val + "\n" : "";
1865
+ }
1866
+ return "";
1867
+ }
1868
+ }
1869
+ // catch-all for git
1870
+ if (/^git\s/.test(cmd)) return "";
1871
+ if (cmd === "true" || cmd === ":") return "";
1872
+ if (cmd === "pwd") return (opts?.cwd || "/") + "\n";
1873
+ if (cmd.startsWith("cat ") && _vol) {
1874
+ const path = cmd.slice(4).trim().replace(/['"]/g, "");
1875
+ try {
1876
+ return _vol.readFileSync(path, "utf8" as any);
1877
+ } catch {
1878
+ return "";
1879
+ }
1880
+ }
1881
+ if ((cmd === "ls" || cmd.startsWith("ls ")) && _vol) {
1882
+ const dir =
1883
+ cmd === "ls"
1884
+ ? opts?.cwd || "/"
1885
+ : cmd.slice(3).trim().replace(/['"]/g, "");
1886
+ try {
1887
+ return _vol.readdirSync(dir).join("\n") + "\n";
1888
+ } catch {
1889
+ return "";
1890
+ }
1891
+ }
1892
+ const testMatch = cmd.match(
1893
+ /^(?:test|\[)\s+(-[fd])\s+["']?(.*?)["']?\s*\]?\s*$/,
1894
+ );
1895
+ if (testMatch && _vol) {
1896
+ const flag = testMatch[1];
1897
+ const path = testMatch[2];
1898
+ try {
1899
+ const st = _vol.statSync(path);
1900
+ if (flag === "-f" && st.isFile()) return "";
1901
+ if (flag === "-d" && st.isDirectory()) return "";
1902
+ } catch {
1903
+ /* */
1904
+ }
1905
+ return "";
1906
+ }
1907
+ return null;
1908
+ }
1909
+
1910
+ export function spawn(
1911
+ command: string,
1912
+ argsOrOpts?: string[] | SpawnConfig,
1913
+ opts?: SpawnConfig,
1914
+ ): ShellProcess {
1915
+ let spawnArgs: string[] = [];
1916
+ let cfg: SpawnConfig = {};
1917
+ if (Array.isArray(argsOrOpts)) {
1918
+ spawnArgs = argsOrOpts;
1919
+ cfg = opts ?? {};
1920
+ } else if (argsOrOpts) cfg = argsOrOpts;
1921
+
1922
+ const child = new ShellProcess();
1923
+
1924
+ // spawn() gets a dedicated worker (streaming output, long-lived processes).
1925
+ // exec() runs inline since it collects all output at the end.
1926
+ if (_spawnChildFn) {
1927
+ const cwd = cfg.cwd ?? getShellCwd();
1928
+ const env = (cfg.env as Record<string, string>) ?? {};
1929
+ const fullCmd = spawnArgs.length ? `${command} ${spawnArgs.join(" ")}` : command;
1930
+
1931
+ // keep parent alive while child is running
1932
+ ref();
1933
+
1934
+ _spawnChildFn(command, spawnArgs, {
1935
+ cwd,
1936
+ env,
1937
+ stdio: "pipe",
1938
+ onStdout: (data: string) => {
1939
+ child.stdout?.push(Buffer.from(data));
1940
+ // also route through parent's stdout sink for terminal output
1941
+ const sink = getStdoutSink();
1942
+ if (sink) sink(data);
1943
+ },
1944
+ onStderr: (data: string) => {
1945
+ child.stderr?.push(Buffer.from(data));
1946
+ const sink = getStderrSink();
1947
+ if (sink) sink(data);
1948
+ },
1949
+ }).then(({ exitCode }) => {
1950
+ unref(); // Child done — release event loop hold
1951
+ child.stdout?.push(null);
1952
+ child.stderr?.push(null);
1953
+ child.exitCode = exitCode;
1954
+ child.emit("close", exitCode, null);
1955
+ child.emit("exit", exitCode, null);
1956
+ }).catch((e) => {
1957
+ unref(); // Child done — release event loop hold
1958
+ child.emit("error", e instanceof Error ? e : new Error(String(e)));
1959
+ });
1960
+ } else if (_shell) {
1961
+ // fallback: inline execution (no streaming)
1962
+ const cwd = cfg.cwd ?? getShellCwd();
1963
+ const env = (cfg.env as Record<string, string>) ?? undefined;
1964
+ const fullCmd = spawnArgs.length
1965
+ ? `${command} ${spawnArgs.map((a) => a.includes(" ") ? `"${a}"` : a).join(" ")}`
1966
+ : command;
1967
+
1968
+ _shell.exec(fullCmd, { cwd, env }).then(
1969
+ (result) => {
1970
+ const { stdout, stderr, exitCode } = result;
1971
+ if (stdout) child.stdout?.push(Buffer.from(stdout));
1972
+ if (stderr) child.stderr?.push(Buffer.from(stderr));
1973
+ child.stdout?.push(null);
1974
+ child.stderr?.push(null);
1975
+ child.exitCode = exitCode;
1976
+ child.emit("close", exitCode, null);
1977
+ child.emit("exit", exitCode, null);
1978
+ },
1979
+ (e) => {
1980
+ child.emit("error", e instanceof Error ? e : new Error(String(e)));
1981
+ },
1982
+ );
1983
+ } else {
1984
+ setTimeout(() => {
1985
+ child.emit("error", new Error("[Nodepod] spawn requires shell or worker mode."));
1986
+ }, 0);
1987
+ }
1988
+
1989
+ return child;
1990
+ }
1991
+
1992
+ export function spawnSync(
1993
+ cmd: string,
1994
+ args?: string[] | SpawnConfig,
1995
+ opts?: SpawnConfig,
1996
+ ): {
1997
+ stdout: Buffer;
1998
+ stderr: Buffer;
1999
+ status: number;
2000
+ signal: null;
2001
+ pid: number;
2002
+ output: [null, Buffer, Buffer];
2003
+ error?: Error;
2004
+ } {
2005
+ let spawnArgs: string[] = [];
2006
+ let cfg: SpawnConfig = {};
2007
+ if (Array.isArray(args)) {
2008
+ spawnArgs = args;
2009
+ cfg = opts ?? {};
2010
+ } else if (args) {
2011
+ cfg = args;
2012
+ }
2013
+
2014
+ const full = spawnArgs.length ? `${cmd} ${spawnArgs.join(" ")}` : cmd;
2015
+ const syncResult = handleSyncCommand(full, { cwd: cfg.cwd, env: cfg.env });
2016
+
2017
+ if (syncResult !== null) {
2018
+ const stdout = Buffer.from(syncResult);
2019
+ const stderr = Buffer.from("");
2020
+ return {
2021
+ stdout,
2022
+ stderr,
2023
+ status: 0,
2024
+ signal: null,
2025
+ pid: MOCK_PID.BASE + Math.floor(Math.random() * MOCK_PID.RANGE),
2026
+ output: [null, stdout, stderr],
2027
+ };
2028
+ }
2029
+
2030
+ // true blocking path via Atomics.wait()
2031
+ if (!_syncChannel) {
2032
+ throw new Error(
2033
+ "[Nodepod] spawnSync requires SyncChannel (worker mode with SharedArrayBuffer). " +
2034
+ "Ensure Nodepod is running in worker mode with COOP/COEP headers.",
2035
+ );
2036
+ }
2037
+
2038
+ const slot = _syncChannel.allocateSlot();
2039
+ const cwd = cfg.cwd ?? (globalThis as any).process?.cwd?.() ?? "/";
2040
+ const env = (cfg.env as Record<string, string>) ?? {};
2041
+
2042
+ (self as any).postMessage({
2043
+ type: "spawn-sync",
2044
+ requestId: _nextSyncRequestId++,
2045
+ command: full.split(/\s+/)[0],
2046
+ args: full.split(/\s+/).slice(1),
2047
+ cwd,
2048
+ env,
2049
+ syncSlot: slot,
2050
+ shellCommand: full,
2051
+ });
2052
+
2053
+ // blocks until main thread spawns child and child completes
2054
+ try {
2055
+ const { exitCode, stdout: stdoutStr } = _syncChannel.waitForResult(slot, 120_000);
2056
+ const stdout = Buffer.from(stdoutStr);
2057
+ const stderr = Buffer.from("");
2058
+ return {
2059
+ stdout,
2060
+ stderr,
2061
+ status: exitCode,
2062
+ signal: null,
2063
+ pid: MOCK_PID.BASE + Math.floor(Math.random() * MOCK_PID.RANGE),
2064
+ output: [null, stdout, stderr],
2065
+ };
2066
+ } catch (e: any) {
2067
+ const stdout = Buffer.from(e?.stdout ?? "");
2068
+ const stderr = Buffer.from(e?.message ?? "");
2069
+ return {
2070
+ stdout,
2071
+ stderr,
2072
+ status: e?.status ?? 1,
2073
+ signal: null,
2074
+ pid: MOCK_PID.BASE + Math.floor(Math.random() * MOCK_PID.RANGE),
2075
+ output: [null, stdout, stderr],
2076
+ error: e instanceof Error ? e : new Error(String(e)),
2077
+ };
2078
+ }
2079
+ }
2080
+
2081
+ export function execFileSync(
2082
+ file: string,
2083
+ args?: string[],
2084
+ opts?: RunOptions,
2085
+ ): string | Buffer {
2086
+ const cmd = args?.length ? `${file} ${args.join(" ")}` : file;
2087
+ return execSync(cmd, opts);
2088
+ }
2089
+
2090
+ export function execFile(
2091
+ file: string,
2092
+ argsOrOpts?: string[] | RunOptions | RunCallback,
2093
+ optsOrCb?: RunOptions | RunCallback,
2094
+ cb?: RunCallback,
2095
+ ): ShellProcess {
2096
+ let fileArgs: string[] = [];
2097
+ let options: RunOptions = {};
2098
+ let done: RunCallback | undefined;
2099
+
2100
+ if (Array.isArray(argsOrOpts)) {
2101
+ fileArgs = argsOrOpts;
2102
+ if (typeof optsOrCb === "function") done = optsOrCb;
2103
+ else if (optsOrCb) {
2104
+ options = optsOrCb;
2105
+ done = cb;
2106
+ }
2107
+ } else if (typeof argsOrOpts === "function") {
2108
+ done = argsOrOpts;
2109
+ } else if (argsOrOpts) {
2110
+ options = argsOrOpts;
2111
+ done = optsOrCb as RunCallback;
2112
+ }
2113
+
2114
+ const cmd = fileArgs.length ? `${file} ${fileArgs.join(" ")}` : file;
2115
+ return exec(cmd, options, done);
2116
+ }
2117
+
2118
+ export function fork(
2119
+ modulePath: string,
2120
+ argsOrOpts?: string[] | Record<string, unknown>,
2121
+ opts?: Record<string, unknown>,
2122
+ ): ShellProcess {
2123
+ let args: string[] = [];
2124
+ let cfg: Record<string, unknown> = {};
2125
+ if (Array.isArray(argsOrOpts)) {
2126
+ args = argsOrOpts;
2127
+ cfg = opts ?? {};
2128
+ } else if (argsOrOpts) cfg = argsOrOpts;
2129
+
2130
+ const cwd = (cfg.cwd as string) || getShellCwd();
2131
+ const env = (cfg.env as Record<string, string>) ||
2132
+ (_shell?.getEnv() ?? {});
2133
+
2134
+ const resolved = modulePath.startsWith("/")
2135
+ ? modulePath
2136
+ : `${cwd}/${modulePath}`.replace(/\/+/g, "/");
2137
+
2138
+ const child = new ShellProcess();
2139
+ child.connected = true;
2140
+ child.spawnargs = ["node", resolved, ...args];
2141
+ child.spawnfile = "node";
2142
+
2143
+ if (!_forkChildFn) {
2144
+ setTimeout(() => {
2145
+ child.emit("error", new Error("[Nodepod] fork requires worker mode. No forkChild callback set."));
2146
+ }, 0);
2147
+ return child;
2148
+ }
2149
+
2150
+ // keep parent alive while forked child is running
2151
+ ref();
2152
+ const handle = _forkChildFn(resolved, args, {
2153
+ cwd,
2154
+ env,
2155
+ onStdout: (data: string) => {
2156
+ child.stdout?.emit("data", data);
2157
+ // also route through parent's stdout sink (fork inherits stdio by default)
2158
+ const sink = getStdoutSink();
2159
+ if (sink) sink(data);
2160
+ },
2161
+ onStderr: (data: string) => {
2162
+ child.stderr?.emit("data", data);
2163
+ const sink = getStderrSink();
2164
+ if (sink) sink(data);
2165
+ },
2166
+ onIPC: (data: unknown) => {
2167
+ child.emit("message", data);
2168
+ },
2169
+ onExit: (exitCode: number) => {
2170
+ unref(); // Child done — release event loop hold
2171
+ child.exitCode = exitCode;
2172
+ child.connected = false;
2173
+ child.emit("exit", exitCode, null);
2174
+ child.emit("close", exitCode, null);
2175
+ },
2176
+ });
2177
+
2178
+ // parent→child IPC
2179
+ child.send = (msg: unknown, _cb?: (e: Error | null) => void): boolean => {
2180
+ if (!child.connected) return false;
2181
+ handle.sendIPC(msg);
2182
+ return true;
2183
+ };
2184
+
2185
+ child.kill = (sig?: string): boolean => {
2186
+ child.killed = true;
2187
+ child.connected = false;
2188
+ handle.disconnect();
2189
+ child.emit("exit", null, sig ?? "SIGTERM");
2190
+ child.emit("close", null, sig ?? "SIGTERM");
2191
+ return true;
2192
+ };
2193
+
2194
+ child.disconnect = (): void => {
2195
+ child.connected = false;
2196
+ handle.disconnect();
2197
+ child.emit("disconnect");
2198
+ };
2199
+
2200
+ return child;
2201
+ }
2202
+
2203
+ export interface ShellProcess extends EventEmitter {
2204
+ pid: number;
2205
+ connected: boolean;
2206
+ killed: boolean;
2207
+ exitCode: number | null;
2208
+ signalCode: string | null;
2209
+ spawnargs: string[];
2210
+ spawnfile: string;
2211
+ stdin: Writable | null;
2212
+ stdout: Readable | null;
2213
+ stderr: Readable | null;
2214
+ kill(sig?: string): boolean;
2215
+ disconnect(): void;
2216
+ send(msg: unknown, cb?: (e: Error | null) => void): boolean;
2217
+ ref(): this;
2218
+ unref(): this;
2219
+ }
2220
+
2221
+ interface ShellProcessConstructor {
2222
+ new (): ShellProcess;
2223
+ (this: any): void;
2224
+ prototype: any;
2225
+ }
2226
+
2227
+ export const ShellProcess = function ShellProcess(this: any) {
2228
+ if (!this) return;
2229
+ EventEmitter.call(this);
2230
+ this.pid = MOCK_PID.BASE + Math.floor(Math.random() * MOCK_PID.RANGE);
2231
+ this.connected = false;
2232
+ this.killed = false;
2233
+ this.exitCode = null;
2234
+ this.signalCode = null;
2235
+ this.spawnargs = [];
2236
+ this.spawnfile = "";
2237
+ this.stdin = new Writable();
2238
+ this.stdout = new Readable();
2239
+ this.stderr = new Readable();
2240
+ } as unknown as ShellProcessConstructor;
2241
+
2242
+ Object.setPrototypeOf(ShellProcess.prototype, EventEmitter.prototype);
2243
+
2244
+ ShellProcess.prototype.kill = function kill(this: any, sig?: string): boolean {
2245
+ this.killed = true;
2246
+ this.emit("exit", null, sig ?? "SIGTERM");
2247
+ return true;
2248
+ };
2249
+
2250
+ ShellProcess.prototype.disconnect = function disconnect(this: any): void {
2251
+ this.connected = false;
2252
+ };
2253
+
2254
+ ShellProcess.prototype.send = function send(this: any, msg: unknown, cb?: (e: Error | null) => void): boolean {
2255
+ if (cb) cb(new Error("IPC unavailable"));
2256
+ return false;
2257
+ };
2258
+
2259
+ ShellProcess.prototype.ref = function ref(this: any): any {
2260
+ return this;
2261
+ };
2262
+
2263
+ ShellProcess.prototype.unref = function unref(this: any): any {
2264
+ return this;
2265
+ };
2266
+
2267
+ export default {
2268
+ exec,
2269
+ execSync,
2270
+ execFile,
2271
+ execFileSync,
2272
+ spawn,
2273
+ spawnSync,
2274
+ fork,
2275
+ ShellProcess,
2276
+ initShellExec,
2277
+ shellExec,
2278
+ setStreamingCallbacks,
2279
+ clearStreamingCallbacks,
2280
+ sendStdin,
2281
+ setSyncChannel,
2282
+ setSpawnChildCallback,
2283
+ setForkChildCallback,
2284
+ setIPCSend,
2285
+ setIPCReceiveHandler,
2286
+ handleIPCFromParent,
2287
+ executeNodeBinary,
2288
+ };