@nimbus-sh/core 0.6.0 → 0.7.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 (88) hide show
  1. package/README.md +47 -26
  2. package/dist/_shared/byte-stream.d.ts +5 -4
  3. package/dist/_shared/byte-stream.d.ts.map +1 -1
  4. package/dist/_shared/real-node-imports.d.ts +7 -0
  5. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  6. package/dist/_shared/real-node-imports.js +8 -0
  7. package/dist/_shared/tarball.d.ts +23 -5
  8. package/dist/_shared/tarball.d.ts.map +1 -1
  9. package/dist/_shared/tarball.js +38 -5
  10. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  11. package/dist/_shared/vfs-write-ledger.js +22 -16
  12. package/dist/runtime/comment-strip.d.ts +0 -35
  13. package/dist/runtime/comment-strip.d.ts.map +1 -1
  14. package/dist/runtime/comment-strip.js +48 -20
  15. package/dist/runtime/esbuild-service.d.ts +38 -42
  16. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  17. package/dist/runtime/esbuild-service.js +494 -197
  18. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  19. package/dist/runtime/javascript-ast.js +45 -7
  20. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  21. package/dist/runtime/opentui-wasm-backend.js +18 -16
  22. package/dist/runtime/port-registry.d.ts +4 -2
  23. package/dist/runtime/port-registry.d.ts.map +1 -1
  24. package/dist/runtime/port-registry.js +60 -8
  25. package/dist/shell/stdin-adapter.d.ts +11 -0
  26. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  27. package/dist/shell/stdin-adapter.js +59 -0
  28. package/dist/shell/unix-commands.d.ts +2 -1
  29. package/dist/shell/unix-commands.d.ts.map +1 -1
  30. package/dist/shell/unix-commands.js +1078 -50
  31. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  32. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  33. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  34. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  35. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  36. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  37. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  38. package/dist/substrate/lifo/commands/text/head.js +2 -1
  39. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  40. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  41. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  42. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  43. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  44. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  45. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  46. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  47. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  48. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  49. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  50. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  51. package/dist/substrate/lifo/shell/pipe.js +62 -18
  52. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  53. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  54. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  55. package/dist/vfs/sqlite-vfs.d.ts +19 -0
  56. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  57. package/dist/vfs/sqlite-vfs.js +106 -23
  58. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  59. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  60. package/dist/workspace/nimbus-workspace.js +20 -3
  61. package/dist/workspace/supervisor-op.d.ts +23 -0
  62. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  63. package/dist/workspace/supervisor-op.js +92 -0
  64. package/package.json +2 -2
  65. package/src/_shared/byte-stream.ts +5 -4
  66. package/src/_shared/real-node-imports.ts +8 -0
  67. package/src/_shared/tarball.ts +56 -5
  68. package/src/_shared/vfs-write-ledger.ts +22 -16
  69. package/src/runtime/comment-strip.ts +42 -23
  70. package/src/runtime/esbuild-service.ts +566 -217
  71. package/src/runtime/javascript-ast.ts +40 -9
  72. package/src/runtime/opentui-wasm-backend.ts +19 -17
  73. package/src/runtime/port-registry.ts +66 -8
  74. package/src/shell/stdin-adapter.ts +58 -0
  75. package/src/shell/unix-commands.ts +1054 -50
  76. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  77. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  78. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  79. package/src/substrate/lifo/commands/text/head.ts +2 -1
  80. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  81. package/src/substrate/lifo/commands/types.ts +8 -5
  82. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  83. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  84. package/src/substrate/lifo/shell/pipe.ts +65 -24
  85. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  86. package/src/vfs/sqlite-vfs.ts +104 -23
  87. package/src/workspace/nimbus-workspace.ts +26 -2
  88. package/src/workspace/supervisor-op.ts +117 -0
@@ -91,7 +91,7 @@ async function copyFromStdin(ctx: CommandContext, options: DdOptions): Promise<n
91
91
  skipRemaining -= chunk.length;
92
92
  continue;
93
93
  }
94
- const bytes = encode(chunk);
94
+ const bytes = typeof chunk === 'string' ? encode(chunk) : chunk;
95
95
  sink.write(bytes);
96
96
  copied += bytes.length;
97
97
  }
@@ -99,7 +99,7 @@ async function copyFromStdin(ctx: CommandContext, options: DdOptions): Promise<n
99
99
  return copied;
100
100
  }
101
101
 
102
- async function readStdinChunk(stdin: CommandInputStream, want: number): Promise<string | null> {
102
+ async function readStdinChunk(stdin: CommandInputStream, want: number): Promise<Uint8Array | string | null> {
103
103
  if (stdin.readBytes) return stdin.readBytes(want);
104
104
  return stdin.read();
105
105
  }
@@ -12,6 +12,8 @@ type CurlOptions = {
12
12
  dataParts: CurlDataPart[];
13
13
  data?: string;
14
14
  outputFile?: string;
15
+ /** -D/--dump-header target: response headers go to this file, or to stdout when it is '-'. */
16
+ dumpHeaders?: string;
15
17
  silent: boolean;
16
18
  showError: boolean;
17
19
  followRedirects: boolean;
@@ -58,9 +60,101 @@ type VirtualResponse = {
58
60
  _donePromise?: Promise<void>;
59
61
  };
60
62
 
61
- const SHORT_VALUE_OPTIONS = new Set(['X', 'H', 'd', 'o', 'w']);
63
+ const SHORT_VALUE_OPTIONS = new Set(['X', 'H', 'd', 'o', 'w', 'D']);
64
+
65
+ /** What a header dump needs from a response — native Response or shaped. */
66
+ type CurlHeaderSource = {
67
+ status: number;
68
+ statusText: string;
69
+ headers: Headers | Record<string, string>;
70
+ };
62
71
  const MAX_REDIRECTS = 20;
63
72
 
73
+ /** curl's exit code for local write failures (-o/-D targets). */
74
+ const CURL_WRITE_ERROR_EXIT = 23;
75
+
76
+ /**
77
+ * A dump-target failure is a local write problem, not a transport error: it
78
+ * must surface as exit 23 with the precise cause, never as a rejected
79
+ * virtual call or a generic connection-failure exit 7.
80
+ */
81
+ class CurlHeaderWriteError extends Error {
82
+ constructor(action: string, target: string, cause: unknown) {
83
+ const detail = cause instanceof Error ? cause.message : String(cause);
84
+ super(`${action} dump-header file '${target}': ${detail}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Transfer-scoped sink for -D/--dump-header, opened once per command before
90
+ * the request. '-' streams straight to stdout with no init; a VFS path is
91
+ * truncated into existence up front — an empty file is the truthful record
92
+ * of a response that never arrived — and every received header block is
93
+ * persisted on arrival, before any body drain. With no -D flag at all the
94
+ * sink is inert; an empty-string target is still a target — it resolves to
95
+ * the cwd, the write is attempted up front, and failing it exits 23.
96
+ */
97
+ class CurlHeaderSink {
98
+ private readonly ctx: Parameters<Command>[0];
99
+ private readonly target: string | null;
100
+ private contents: string[] = [];
101
+
102
+ constructor(ctx: Parameters<Command>[0], target: string | null) {
103
+ this.ctx = ctx;
104
+ this.target = target;
105
+ }
106
+
107
+ /** True when no -D was given: every sink method is a no-op. */
108
+ get inert(): boolean {
109
+ return this.target === null;
110
+ }
111
+
112
+ /** Truncate/create the file target up front; '-' needs no init, no -D skips. */
113
+ async open(): Promise<void> {
114
+ if (this.target === null || this.target === '-') return;
115
+ try {
116
+ this.ctx.vfs.writeFile(resolve(this.ctx.cwd, this.target), '');
117
+ } catch (error) {
118
+ throw new CurlHeaderWriteError('create', this.target, error);
119
+ }
120
+ }
121
+
122
+ /** Persist one received response's header block, in arrival order. */
123
+ async writeBlock(response: CurlHeaderSource): Promise<void> {
124
+ if (this.target === null) return;
125
+ const block = curlHeaderBlock(response);
126
+ try {
127
+ if (this.target === '-') {
128
+ this.ctx.stdout.write(block);
129
+ return;
130
+ }
131
+ this.contents.push(block);
132
+ this.ctx.vfs.writeFile(resolve(this.ctx.cwd, this.target), this.contents.join(''));
133
+ } catch (error) {
134
+ throw new CurlHeaderWriteError('write', this.target, error);
135
+ }
136
+ }
137
+ }
138
+
139
+ function reportCurlWriteError(ctx: Parameters<Command>[0], error: CurlHeaderWriteError): number {
140
+ ctx.stderr.write(`curl: (${CURL_WRITE_ERROR_EXIT}) Failed ${error.message}\n`);
141
+ return CURL_WRITE_ERROR_EXIT;
142
+ }
143
+
144
+ /**
145
+ * Persist one arrived response block; a failing dump target cancels the
146
+ * undrained body before the write error surfaces, so the abandoned
147
+ * transfer leaks no stream on its way to exit 23.
148
+ */
149
+ async function writeHeaderDump(sink: CurlHeaderSink, response: Response): Promise<void> {
150
+ try {
151
+ await sink.writeBlock(response);
152
+ } catch (error) {
153
+ cancelStreamBody(response.body);
154
+ throw error;
155
+ }
156
+ }
157
+
64
158
  function createCurlImpl(kernel?: Kernel): Command {
65
159
  return async (ctx) => {
66
160
  const options = parseCurlArgs(ctx.args);
@@ -68,7 +162,7 @@ function createCurlImpl(kernel?: Kernel): Command {
68
162
 
69
163
  if (!url) {
70
164
  ctx.stderr.write('curl: no URL specified\n');
71
- ctx.stderr.write('Usage: curl [-fsSLI#] [-X method] [-H header] [-d data] [-o file] [-w format] url\n');
165
+ ctx.stderr.write('Usage: curl [-fsSLI#] [-X method] [-H header] [-d data] [-o file] [-D file] [-w format] url\n');
72
166
  return 1;
73
167
  }
74
168
 
@@ -84,49 +178,50 @@ function createCurlImpl(kernel?: Kernel): Command {
84
178
  return 26;
85
179
  }
86
180
 
87
- if (kernel?.portRegistry) {
88
- const virtual = await resolveVirtualCurlResponse(kernel, ctx, options, url);
89
- if (virtual?.kind === 'response') {
90
- return handleCurlResponse(ctx, options, virtual.response);
91
- }
92
- if (virtual?.kind === 'external') {
93
- url = virtual.url;
94
- }
95
- if (virtual?.kind === 'error') {
96
- return virtual.exitCode;
97
- }
98
- }
99
-
181
+ const headers = new CurlHeaderSink(ctx, options.dumpHeaders ?? null);
100
182
  try {
101
183
  const requestSignal = createRequestSignal(ctx.signal, options.maxTimeSeconds);
102
- const fetchOptions: RequestInit = {
103
- method: options.method,
104
- headers: Object.keys(options.headers).length > 0 ? options.headers : undefined,
105
- body: options.data,
106
- redirect: options.followRedirects ? 'follow' : 'manual',
107
- signal: requestSignal.signal,
108
- };
109
-
110
184
  try {
111
- const response = await fetch(url, fetchOptions);
112
- const effectiveUrl = response.url || url;
113
- // Body streams to stdout as it arrives (SSE/chunked responses flow
114
- // live); an -o file needs the whole payload for a single VFS write.
115
- const body = options.outputFile
116
- ? new Uint8Array(await response.arrayBuffer())
117
- : response.body ?? '';
118
- return await handleCurlResponse(ctx, options, {
119
- status: response.status,
120
- statusText: response.statusText,
121
- headers: headersToRecord(response.headers),
122
- body,
123
- url: effectiveUrl,
185
+ await headers.open();
186
+
187
+ if (kernel?.portRegistry) {
188
+ const virtual = await resolveVirtualCurlResponse(kernel, ctx, options, url, headers);
189
+ if (virtual?.kind === 'response') {
190
+ return handleCurlResponse(ctx, options, virtual.response);
191
+ }
192
+ if (virtual?.kind === 'external') {
193
+ url = virtual.url;
194
+ }
195
+ if (virtual?.kind === 'error') {
196
+ return virtual.exitCode;
197
+ }
198
+ }
199
+
200
+ // -L with a dump target needs every hop's headers, so redirects are
201
+ // followed manually here; without -D the fetch-native follow keeps
202
+ // its proven semantics.
203
+ if (options.followRedirects && !headers.inert) {
204
+ return await followExternalWithDump(ctx, options, url, headers, requestSignal.signal);
205
+ }
206
+
207
+ const response = await fetch(url, {
208
+ method: options.method,
209
+ headers: Object.keys(options.headers).length > 0 ? options.headers : undefined,
210
+ body: options.data,
211
+ redirect: options.followRedirects ? 'follow' : 'manual',
212
+ signal: requestSignal.signal,
124
213
  });
214
+ // Headers are dumped on arrival — before any arrayBuffer()/body drain.
215
+ await writeHeaderDump(headers, response);
216
+ return await handleCurlResponse(ctx, options, await drainCurlResponse(options, response, response.url || url));
125
217
  } finally {
126
218
  requestSignal.cleanup();
127
219
  }
128
- } catch (e) {
129
- const msg = e instanceof Error ? e.message : String(e);
220
+ } catch (error) {
221
+ if (error instanceof CurlHeaderWriteError) {
222
+ return reportCurlWriteError(ctx, error);
223
+ }
224
+ const msg = error instanceof Error ? error.message : String(error);
130
225
  if (msg === CURL_TIMEOUT_ERROR) {
131
226
  ctx.stderr.write(`curl: operation timed out after ${options.maxTimeSeconds} seconds\n`);
132
227
  } else if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('CORS')) {
@@ -140,6 +235,120 @@ function createCurlImpl(kernel?: Kernel): Command {
140
235
  };
141
236
  }
142
237
 
238
+ /** Request headers fetch-follow strips when a redirect crosses origins. */
239
+ const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'cookie2'];
240
+
241
+ /** Content headers that only make sense on a request that carries a body. */
242
+ const CONTENT_HEADERS = ['content-type', 'content-length', 'transfer-encoding', 'expect'];
243
+
244
+ /**
245
+ * Bounded manual redirect walk for external -L with a dump target: every
246
+ * hop's header block is written on arrival, and the final response goes
247
+ * through normal handling; exceeding the cap reports curl's redirect error.
248
+ * Request mutation mirrors fetch-follow: 301/302 collapse only POST to a
249
+ * bodyless GET, 303 collapses everything but HEAD, 307/308 preserve method
250
+ * and body; content headers go with the body, and credential headers are
251
+ * stripped when a hop crosses origins.
252
+ */
253
+ async function followExternalWithDump(
254
+ ctx: Parameters<Command>[0],
255
+ options: CurlOptions,
256
+ startUrl: string,
257
+ headers: CurlHeaderSink,
258
+ signal: AbortSignal,
259
+ ): Promise<number> {
260
+ let current = new URL(startUrl);
261
+ let method = options.method;
262
+ let data = options.data;
263
+ let requestHeaders = new Headers(Object.keys(options.headers).length > 0 ? options.headers : {});
264
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
265
+ const response = await fetch(current, {
266
+ method,
267
+ headers: [...requestHeaders.keys()].length > 0 ? requestHeaders : undefined,
268
+ body: data,
269
+ redirect: 'manual',
270
+ signal,
271
+ });
272
+ // Dump on arrival — before any drain of this hop's body.
273
+ await writeHeaderDump(headers, response);
274
+ const record = headersToRecord(response.headers);
275
+
276
+ if (!isRedirectStatus(response.status)) {
277
+ return await handleCurlResponse(
278
+ ctx,
279
+ options,
280
+ await drainCurlResponse(options, response, response.url || current.toString()),
281
+ );
282
+ }
283
+ const location = getHeader(record, 'location');
284
+ if (!location) {
285
+ // Redirect status without a target: nothing to follow, handle as final.
286
+ return await handleCurlResponse(
287
+ ctx,
288
+ options,
289
+ await drainCurlResponse(options, response, response.url || current.toString()),
290
+ );
291
+ }
292
+ if (redirects === MAX_REDIRECTS) {
293
+ cancelStreamBody(response.body);
294
+ break;
295
+ }
296
+
297
+ let next: URL;
298
+ try {
299
+ next = new URL(location, current);
300
+ } catch {
301
+ // Unusable Location: this hop is as final as it gets — the body must
302
+ // still be drainable, so it is only released once we decide to follow.
303
+ return await handleCurlResponse(
304
+ ctx,
305
+ options,
306
+ await drainCurlResponse(options, response, current.toString()),
307
+ );
308
+ }
309
+ // Follow decision made: this hop's body is no longer needed.
310
+ cancelStreamBody(response.body);
311
+ if ([301, 302].includes(response.status) && method === 'POST') {
312
+ method = 'GET';
313
+ data = undefined;
314
+ } else if (response.status === 303 && method !== 'HEAD') {
315
+ method = 'GET';
316
+ data = undefined;
317
+ }
318
+ if (data === undefined) {
319
+ for (const name of CONTENT_HEADERS) requestHeaders.delete(name);
320
+ }
321
+ if (next.origin !== current.origin) {
322
+ for (const name of CREDENTIAL_HEADERS) requestHeaders.delete(name);
323
+ }
324
+ current = next;
325
+ }
326
+ ctx.stderr.write(`curl: (47) Maximum (${MAX_REDIRECTS}) redirects followed\n`);
327
+ return 47;
328
+ }
329
+
330
+ /**
331
+ * Shape a native Response for response handling. Body streams to stdout as
332
+ * they arrive (SSE/chunked responses flow live); an -o file needs the whole
333
+ * payload for a single VFS write.
334
+ */
335
+ async function drainCurlResponse(
336
+ options: CurlOptions,
337
+ response: Response,
338
+ url: string,
339
+ ): Promise<CurlResponse> {
340
+ const body = options.outputFile
341
+ ? new Uint8Array(await response.arrayBuffer())
342
+ : response.body ?? '';
343
+ return {
344
+ status: response.status,
345
+ statusText: response.statusText,
346
+ headers: headersToRecord(response.headers),
347
+ body,
348
+ url,
349
+ };
350
+ }
351
+
143
352
  const CURL_TIMEOUT_ERROR = 'curl request timeout';
144
353
 
145
354
  function parseCurlArgs(args: string[]): CurlOptions {
@@ -216,6 +425,11 @@ function parseLongOption(options: CurlOptions, arg: string, args: string[], inde
216
425
  options.outputFile = value;
217
426
  return consumed;
218
427
  }
428
+ case '--dump-header': {
429
+ const { value, consumed } = readValue();
430
+ options.dumpHeaders = value;
431
+ return consumed;
432
+ }
219
433
  case '--write-out': {
220
434
  const { value, consumed } = readValue();
221
435
  options.writeOut = value;
@@ -315,6 +529,9 @@ function applyShortValueOption(options: CurlOptions, flag: string, value: string
315
529
  case 'w':
316
530
  options.writeOut = value;
317
531
  break;
532
+ case 'D':
533
+ options.dumpHeaders = value;
534
+ break;
318
535
  }
319
536
  }
320
537
 
@@ -373,11 +590,12 @@ async function resolveVirtualCurlResponse(
373
590
  ctx: Parameters<Command>[0],
374
591
  options: CurlOptions,
375
592
  startUrl: string,
593
+ headers: CurlHeaderSink,
376
594
  ): Promise<VirtualCurlResult | null> {
377
595
  let currentUrl = startUrl;
378
596
 
379
597
  for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
380
- const response = await fetchVirtualCurlResponse(kernel, ctx, options, currentUrl);
598
+ const response = await fetchVirtualCurlResponse(kernel, ctx, options, currentUrl, headers);
381
599
  if (!response) {
382
600
  return redirects === 0 ? null : { kind: 'external', url: currentUrl };
383
601
  }
@@ -416,6 +634,7 @@ async function routeCurlOverLoopback(
416
634
  requestUrl: URL,
417
635
  url: string,
418
636
  port: number,
637
+ headers: CurlHeaderSink,
419
638
  ): Promise<CurlResponse | null> {
420
639
  if (!kernel.routeLoopback) return null;
421
640
  const hasBody = options.method !== 'GET' && options.method !== 'HEAD' && options.data !== undefined;
@@ -426,6 +645,8 @@ async function routeCurlOverLoopback(
426
645
  signal: ctx.signal,
427
646
  }));
428
647
  if (!response) return null;
648
+ // Dump on arrival — before the optional arrayBuffer() drain below.
649
+ await writeHeaderDump(headers, response);
429
650
  return {
430
651
  status: response.status,
431
652
  statusText: response.statusText,
@@ -444,6 +665,7 @@ async function fetchVirtualCurlResponse(
444
665
  ctx: Parameters<Command>[0],
445
666
  options: CurlOptions,
446
667
  url: string,
668
+ headers: CurlHeaderSink,
447
669
  ): Promise<CurlResponse | { exitCode: number } | null> {
448
670
  let requestUrl: URL;
449
671
  try {
@@ -468,7 +690,7 @@ async function fetchVirtualCurlResponse(
468
690
  if (!requestCarriesSessionAiToken(new Headers(options.headers), ctx.env[NIMBUS_AI_TOKEN_ENV] || '')) {
469
691
  return null;
470
692
  }
471
- return routeCurlOverLoopback(kernel, ctx, options, requestUrl, url, NIMBUS_AI_GATEWAY_PORT);
693
+ return routeCurlOverLoopback(kernel, ctx, options, requestUrl, url, NIMBUS_AI_GATEWAY_PORT, headers);
472
694
  }
473
695
 
474
696
  const handler = kernel.portRegistry.get(port);
@@ -498,6 +720,11 @@ async function fetchVirtualCurlResponse(
498
720
  }
499
721
  }
500
722
 
723
+ await headers.writeBlock({
724
+ status: vRes.statusCode,
725
+ statusText: statusText(vRes.statusCode),
726
+ headers: vRes.headers,
727
+ });
501
728
  return {
502
729
  status: vRes.statusCode,
503
730
  statusText: statusText(vRes.statusCode),
@@ -507,7 +734,7 @@ async function fetchVirtualCurlResponse(
507
734
  };
508
735
  }
509
736
 
510
- const routed = await routeCurlOverLoopback(kernel, ctx, options, requestUrl, url, port);
737
+ const routed = await routeCurlOverLoopback(kernel, ctx, options, requestUrl, url, port, headers);
511
738
  if (routed) return routed;
512
739
 
513
740
  ctx.stderr.write(`curl: (7) Failed to connect to ${requestUrl.hostname} port ${port}\n`);
@@ -520,7 +747,7 @@ async function handleCurlResponse(ctx: Parameters<Command>[0], options: CurlOpti
520
747
  if (options.headOnly) {
521
748
  cancelStreamBody(response.body);
522
749
  if (!failed) {
523
- await writeCurlOutput(ctx, options, curlHeaders(response));
750
+ await writeCurlOutput(ctx, options, curlHeaderBlock(response));
524
751
  }
525
752
  writeCurlFailure(ctx, options, response);
526
753
  writeCurlWriteOut(ctx, options, response);
@@ -543,18 +770,30 @@ async function handleCurlResponse(ctx: Parameters<Command>[0], options: CurlOpti
543
770
  return curlExitCode(options, response.status);
544
771
  }
545
772
 
546
- function cancelStreamBody(body: CurlBody): void {
773
+ function cancelStreamBody(body: CurlBody | ReadableStream<unknown> | null): void {
547
774
  if (body instanceof ReadableStream) {
548
775
  body.cancel().catch(() => {});
549
776
  }
550
777
  }
551
778
 
779
+ /**
780
+ * --fail diagnostic: curl's own wording pairs its exit code with the
781
+ * offending HTTP status.
782
+ */
552
783
  function writeCurlFailure(ctx: Parameters<Command>[0], options: CurlOptions, response: CurlResponse): void {
553
784
  if (options.fail && options.showError && response.status >= 400) {
554
- ctx.stderr.write(`curl: (${response.status}) The requested URL returned error: ${response.status}\n`);
785
+ ctx.stderr.write(`curl: (22) The requested URL returned error: ${response.status}\n`);
555
786
  }
556
787
  }
557
788
 
789
+ function curlExitCode(options: CurlOptions, status: number): number {
790
+ return options.fail && status >= 400 ? 22 : 0;
791
+ }
792
+
793
+ function isRedirectStatus(status: number): boolean {
794
+ return status >= 300 && status < 400;
795
+ }
796
+
558
797
  async function writeCurlOutput(ctx: Parameters<Command>[0], options: CurlOptions, body: CurlBody): Promise<void> {
559
798
  if (!options.outputFile) {
560
799
  if (typeof body === 'string') {
@@ -641,23 +880,36 @@ function headersToRecord(headers: Headers): Record<string, string> {
641
880
  });
642
881
  return record;
643
882
  }
644
-
645
- function curlHeaders(response: CurlResponse): string {
646
- const lines = [`HTTP/${response.status} ${response.statusText}`];
647
- for (const [key, value] of Object.entries(response.headers)) {
648
- lines.push(`${key}: ${value}`);
883
+ function curlHeaderEntries(headers: Headers | Record<string, string>): [string, string][] {
884
+ if (!(headers instanceof Headers)) return Object.entries(headers);
885
+ // TS's Headers lib type predates getSetCookie; the runtime has it.
886
+ const getSetCookie = (headers as { getSetCookie?: () => string[] }).getSetCookie;
887
+ const pairs: [string, string][] = [];
888
+ for (const [name, value] of headers.entries()) {
889
+ if (typeof getSetCookie === 'function' && name.toLowerCase() === 'set-cookie') continue;
890
+ pairs.push([name, value]);
649
891
  }
650
- return lines.join('\n') + '\n';
651
- }
652
-
653
- function curlExitCode(options: CurlOptions, status: number): number {
654
- return options.fail && status >= 400 ? 22 : 0;
892
+ if (typeof getSetCookie === 'function') {
893
+ for (const value of getSetCookie.call(headers)) pairs.push(['set-cookie', value]);
894
+ }
895
+ return pairs;
655
896
  }
656
-
657
- function isRedirectStatus(status: number): boolean {
658
- return status >= 300 && status < 400;
897
+ /**
898
+ * The received response as an HTTP/1.1 header block: status line plus each
899
+ * header terminated CRLF, closed by the blank CRLF line that ends every
900
+ * header section. fetch exposes no negotiated wire version, so HTTP/1.1 is
901
+ * the explicit compatibility representation — a bare `HTTP/<code>` is not
902
+ * valid status-line syntax. Both -I's stdout display and -D's dump share
903
+ * this one serializer so the two surfaces cannot drift.
904
+ */
905
+ function curlHeaderBlock(response: CurlHeaderSource): string {
906
+ const reason = response.statusText;
907
+ const statusLine = reason
908
+ ? `HTTP/1.1 ${response.status} ${reason}`
909
+ : `HTTP/1.1 ${response.status}`;
910
+ const lines = [statusLine, ...curlHeaderEntries(response.headers).map(([n, v]) => `${n}: ${v}`)];
911
+ return lines.map((line) => `${line}\r\n`).join('') + '\r\n';
659
912
  }
660
-
661
913
  function getHeader(headers: Record<string, string>, name: string): string | undefined {
662
914
  const wanted = name.toLowerCase();
663
915
  for (const [key, value] of Object.entries(headers)) {
@@ -3,7 +3,7 @@ import type { CommandRegistry } from '../registry.js';
3
3
  import type { VFS } from '../../kernel/vfs/index.js';
4
4
  import type { Kernel } from '../../kernel/index.js';
5
5
  import { resolve, join } from '../../utils/path.js';
6
- import { decompressGzip, parseTar } from '../../utils/archive.js';
6
+ import { writeTarballStream, type TarballWriteResult } from '../../../../_shared/tarball.js';
7
7
  import {
8
8
  RegistryPackumentSchema,
9
9
  RegistrySearchResponseSchema,
@@ -36,6 +36,24 @@ export type ShellExecuteFn = (
36
36
  ctx: CommandContext,
37
37
  ) => Promise<number>;
38
38
 
39
+ /** A host may supply a facet-based installer instead of in-process extraction. */
40
+ export interface NpmInstallPort {
41
+ install(
42
+ projectDir: string,
43
+ options: { packages?: string[]; production?: boolean; pid?: number },
44
+ ): Promise<{
45
+ installed: string[];
46
+ failed: string[];
47
+ totalFiles: number;
48
+ elapsed: number;
49
+ cachedHits?: number;
50
+ }>;
51
+ }
52
+
53
+ export interface NpmCommandDeps {
54
+ readonly installer?: NpmInstallPort;
55
+ }
56
+
39
57
  // ─── Helpers ───
40
58
 
41
59
  function getRegistry(env: Record<string, string>): string {
@@ -188,48 +206,19 @@ async function fetchWithRange(
188
206
  return data.versions[matching[0].version];
189
207
  }
190
208
 
191
- async function downloadAndExtract(
209
+ async function fetchAndStreamPackage(
192
210
  tarballUrl: string,
193
211
  targetDir: string,
194
212
  vfs: VFS,
195
213
  signal: AbortSignal,
196
- ): Promise<void> {
214
+ ): Promise<TarballWriteResult> {
197
215
  const response = await fetch(tarballUrl, { signal });
198
216
  if (!response.ok) {
199
217
  throw new Error(`Failed to download tarball: ${response.status}`);
200
218
  }
201
219
 
202
- const arrayBuffer = await response.arrayBuffer();
203
- const compressed = new Uint8Array(arrayBuffer);
204
-
205
- // Decompress gzip → parse tar
206
- const decompressed = await decompressGzip(compressed);
207
- const entries = parseTar(decompressed);
208
-
209
- // Ensure target directory
210
- try { vfs.mkdir(targetDir, { recursive: true }); } catch { /* exists */ }
211
-
212
- // Extract, stripping the first path component (npm tarballs use "package/")
213
- for (const entry of entries) {
214
- const slashIdx = entry.path.indexOf('/');
215
- if (slashIdx === -1) continue;
216
-
217
- const relativePath = entry.path.slice(slashIdx + 1);
218
- if (!relativePath) continue;
219
-
220
- const fullPath = join(targetDir, relativePath);
221
-
222
- if (entry.type === 'directory') {
223
- try { vfs.mkdir(fullPath, { recursive: true }); } catch { /* exists */ }
224
- } else {
225
- // Ensure parent exists
226
- const parent = fullPath.slice(0, fullPath.lastIndexOf('/'));
227
- if (parent) {
228
- try { vfs.mkdir(parent, { recursive: true }); } catch { /* exists */ }
229
- }
230
- vfs.writeFile(fullPath, entry.data);
231
- }
232
- }
220
+ if (!response.body) throw new Error(`Registry served no body for ${tarballUrl}`);
221
+ return writeTarballStream(response.body, targetDir, vfs);
233
222
  }
234
223
 
235
224
  function readProjectPackageJson(vfs: VFS, cwd: string): PackageJson | null {
@@ -300,7 +289,12 @@ async function installSinglePackage(
300
289
 
301
290
  const info = await fetchPackageInfo(npmRegistry, name, version, signal);
302
291
 
303
- await downloadAndExtract(info.dist.tarball, targetDir, vfs, signal);
292
+ const written = await fetchAndStreamPackage(info.dist.tarball, targetDir, vfs, signal);
293
+ if (written.files === 0 || !vfs.exists(join(targetDir, 'package.json'))) {
294
+ throw new Error(
295
+ `${name}: extraction wrote ${written.files} files and left no package.json in ${targetDir}`,
296
+ );
297
+ }
304
298
 
305
299
  let installed = 1;
306
300
 
@@ -378,7 +372,12 @@ async function npmInit(ctx: CommandContext): Promise<number> {
378
372
  return 0;
379
373
  }
380
374
 
381
- async function npmInstall(ctx: CommandContext, registry: CommandRegistry, kernel?: Kernel): Promise<number> {
375
+ async function npmInstall(
376
+ ctx: CommandContext,
377
+ registry: CommandRegistry,
378
+ kernel?: Kernel,
379
+ deps?: NpmCommandDeps,
380
+ ): Promise<number> {
382
381
  const args = ctx.args.slice(1);
383
382
 
384
383
  let isGlobal = false;
@@ -409,6 +408,22 @@ async function npmInstall(ctx: CommandContext, registry: CommandRegistry, kernel
409
408
  try { ctx.vfs.mkdir(GLOBAL_BIN, { recursive: true }); } catch { /* exists */ }
410
409
  }
411
410
 
411
+ if (deps?.installer) {
412
+ const projectDir = isGlobal ? GLOBAL_MODULES : ctx.cwd;
413
+ const result = await deps.installer.install(projectDir, {
414
+ packages: packages.length > 0 ? packages : undefined,
415
+ production: saveDev ? false : undefined,
416
+ pid: ctx.pid,
417
+ });
418
+ for (const failure of result.failed) ctx.stderr.write(`npm ERR! ${failure}\n`);
419
+ ctx.stdout.write(
420
+ `\nadded ${result.installed.length} package${result.installed.length === 1 ? '' : 's'}`
421
+ + ` (${result.totalFiles} files) in ${(result.elapsed / 1000).toFixed(1)}s\n`,
422
+ );
423
+ registerLocalBins(ctx.vfs, projectDir, registry, kernel);
424
+ return result.failed.length > 0 ? 1 : 0;
425
+ }
426
+
412
427
  if (packages.length === 0) {
413
428
  // Install from package.json
414
429
  if (isGlobal) {
@@ -853,7 +868,12 @@ async function npmSearch(ctx: CommandContext): Promise<number> {
853
868
 
854
869
  // ─── Factory ───
855
870
 
856
- export function createNpmCommand(registry: CommandRegistry, shellExecute?: ShellExecuteFn, kernel?: Kernel): Command {
871
+ export function createNpmCommand(
872
+ registry: CommandRegistry,
873
+ shellExecute?: ShellExecuteFn,
874
+ kernel?: Kernel,
875
+ deps?: NpmCommandDeps,
876
+ ): Command {
857
877
  return async (ctx) => {
858
878
  const subcommand = ctx.args[0];
859
879
 
@@ -868,7 +888,7 @@ export function createNpmCommand(registry: CommandRegistry, shellExecute?: Shell
868
888
  case 'install':
869
889
  case 'i':
870
890
  case 'add':
871
- return npmInstall(ctx, registry, kernel);
891
+ return npmInstall(ctx, registry, kernel, deps);
872
892
  case 'uninstall':
873
893
  case 'remove':
874
894
  case 'rm':