@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.
- package/README.md +47 -26
- package/dist/_shared/byte-stream.d.ts +5 -4
- package/dist/_shared/byte-stream.d.ts.map +1 -1
- package/dist/_shared/real-node-imports.d.ts +7 -0
- package/dist/_shared/real-node-imports.d.ts.map +1 -1
- package/dist/_shared/real-node-imports.js +8 -0
- package/dist/_shared/tarball.d.ts +23 -5
- package/dist/_shared/tarball.d.ts.map +1 -1
- package/dist/_shared/tarball.js +38 -5
- package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
- package/dist/_shared/vfs-write-ledger.js +22 -16
- package/dist/runtime/comment-strip.d.ts +0 -35
- package/dist/runtime/comment-strip.d.ts.map +1 -1
- package/dist/runtime/comment-strip.js +48 -20
- package/dist/runtime/esbuild-service.d.ts +38 -42
- package/dist/runtime/esbuild-service.d.ts.map +1 -1
- package/dist/runtime/esbuild-service.js +494 -197
- package/dist/runtime/javascript-ast.d.ts.map +1 -1
- package/dist/runtime/javascript-ast.js +45 -7
- package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
- package/dist/runtime/opentui-wasm-backend.js +18 -16
- package/dist/runtime/port-registry.d.ts +4 -2
- package/dist/runtime/port-registry.d.ts.map +1 -1
- package/dist/runtime/port-registry.js +60 -8
- package/dist/shell/stdin-adapter.d.ts +11 -0
- package/dist/shell/stdin-adapter.d.ts.map +1 -0
- package/dist/shell/stdin-adapter.js +59 -0
- package/dist/shell/unix-commands.d.ts +2 -1
- package/dist/shell/unix-commands.d.ts.map +1 -1
- package/dist/shell/unix-commands.js +1078 -50
- package/dist/substrate/lifo/commands/io/dd.js +1 -1
- package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/net/curl.js +270 -54
- package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
- package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/system/npm.js +26 -43
- package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/text/head.js +2 -1
- package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
- package/dist/substrate/lifo/commands/text/sed.js +499 -117
- package/dist/substrate/lifo/commands/types.d.ts +8 -4
- package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
- package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
- package/dist/substrate/lifo/node-compat/zlib.js +122 -23
- package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
- package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/interpreter.js +79 -29
- package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
- package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/pipe.js +62 -18
- package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
- package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
- package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
- package/dist/vfs/sqlite-vfs.d.ts +19 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
- package/dist/vfs/sqlite-vfs.js +106 -23
- package/dist/workspace/nimbus-workspace.d.ts +15 -0
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +20 -3
- package/dist/workspace/supervisor-op.d.ts +23 -0
- package/dist/workspace/supervisor-op.d.ts.map +1 -0
- package/dist/workspace/supervisor-op.js +92 -0
- package/package.json +2 -2
- package/src/_shared/byte-stream.ts +5 -4
- package/src/_shared/real-node-imports.ts +8 -0
- package/src/_shared/tarball.ts +56 -5
- package/src/_shared/vfs-write-ledger.ts +22 -16
- package/src/runtime/comment-strip.ts +42 -23
- package/src/runtime/esbuild-service.ts +566 -217
- package/src/runtime/javascript-ast.ts +40 -9
- package/src/runtime/opentui-wasm-backend.ts +19 -17
- package/src/runtime/port-registry.ts +66 -8
- package/src/shell/stdin-adapter.ts +58 -0
- package/src/shell/unix-commands.ts +1054 -50
- package/src/substrate/lifo/commands/io/dd.ts +2 -2
- package/src/substrate/lifo/commands/net/curl.ts +310 -58
- package/src/substrate/lifo/commands/system/npm.ts +58 -38
- package/src/substrate/lifo/commands/text/head.ts +2 -1
- package/src/substrate/lifo/commands/text/sed.ts +519 -109
- package/src/substrate/lifo/commands/types.ts +8 -5
- package/src/substrate/lifo/node-compat/zlib.ts +155 -26
- package/src/substrate/lifo/shell/interpreter.ts +74 -27
- package/src/substrate/lifo/shell/pipe.ts +65 -24
- package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
- package/src/vfs/sqlite-vfs.ts +104 -23
- package/src/workspace/nimbus-workspace.ts +26 -2
- 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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 (
|
|
129
|
-
|
|
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,
|
|
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: (
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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 {
|
|
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
|
|
209
|
+
async function fetchAndStreamPackage(
|
|
192
210
|
tarballUrl: string,
|
|
193
211
|
targetDir: string,
|
|
194
212
|
vfs: VFS,
|
|
195
213
|
signal: AbortSignal,
|
|
196
|
-
): Promise<
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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':
|