@volter/tabnode 0.5.47 → 0.5.49
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/dist/assets/{runtime-worker-4iI8R3l-.js → runtime-worker-C1IV4qnr.js} +284 -16
- package/dist/fetch-transport.d.ts +0 -1
- package/dist/index.mjs +288 -20
- package/dist/node-body.d.ts +9 -0
- package/dist/node-response.d.ts +8 -0
- package/package.json +1 -1
- package/src/fetch-transport.ts +7 -3
- package/src/node-body.ts +88 -0
- package/src/node-response.ts +160 -0
- package/src/runtime.ts +10 -0
- package/src/shims/child_process.ts +49 -6
- package/src/shims/shell-jobs.ts +72 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { defineOnHost, takeFromHost } from './host-globals';
|
|
2
|
+
import { nodeResponseBody } from './node-body';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A web `Response` that keeps its `Set-Cookie` headers, as Node's does.
|
|
6
|
+
*
|
|
7
|
+
* Node's `Response` (undici's) is built with a `Headers` that holds every
|
|
8
|
+
* header it was given, `Set-Cookie` and `Set-Cookie2` among them, readable
|
|
9
|
+
* through `get` (joined with ", "), iteration (one entry per cookie) and
|
|
10
|
+
* `getSetCookie()`, and mutable afterwards. A browser's applies the fetch
|
|
11
|
+
* specification's response guard, which drops both silently, on construction
|
|
12
|
+
* and on every later `append` or `set`: `new Response('', { headers:
|
|
13
|
+
* { 'set-cookie': 'a=1' } }).headers` is empty in a tab. A server that answers
|
|
14
|
+
* with a web Response (Next's route handlers and middleware, Hono, Remix,
|
|
15
|
+
* SvelteKit's adapters, NextAuth among them) then loses every cookie it sets
|
|
16
|
+
* before its answer is written to the socket, and no one can sign in.
|
|
17
|
+
*
|
|
18
|
+
* The realm's `Response` stays the browser's own class in all but its
|
|
19
|
+
* constructor: the constructor is a function whose `prototype` is the
|
|
20
|
+
* browser's `Response.prototype`, so a fetch's answer, `Response.error()` and
|
|
21
|
+
* a program's subclass are each `instanceof Response`, share its prototype and
|
|
22
|
+
* name it as their `constructor`, as on Node. A response a program constructs
|
|
23
|
+
* keeps a `Headers` of its own, made without a guard (a `Headers` a program
|
|
24
|
+
* constructs is one, in a browser too), holding the browser's headers and every
|
|
25
|
+
* cookie the program gave; `headers` answers it. The body methods whose answer
|
|
26
|
+
* depends on the content type (`formData`, `blob`) read the kept one, as Node
|
|
27
|
+
* reads the response's own headers.
|
|
28
|
+
*/
|
|
29
|
+
const INSTALLED = Symbol.for('tabnode.response.keepsSetCookie');
|
|
30
|
+
|
|
31
|
+
type ResponseConstructor = typeof Response & { [INSTALLED]?: true };
|
|
32
|
+
|
|
33
|
+
/** The browser's headers with the cookies its guard dropped. */
|
|
34
|
+
function withCookies(own: Headers, given: Headers | undefined): Headers {
|
|
35
|
+
const headers = new Headers(own);
|
|
36
|
+
if (!given) return headers;
|
|
37
|
+
const cookies: string[] = [];
|
|
38
|
+
if (typeof given.getSetCookie === 'function') cookies.push(...given.getSetCookie());
|
|
39
|
+
else given.forEach((value, name) => { if (name === 'set-cookie') cookies.push(value); });
|
|
40
|
+
if (cookies.length) {
|
|
41
|
+
headers.delete('set-cookie');
|
|
42
|
+
for (const cookie of cookies) headers.append('set-cookie', cookie);
|
|
43
|
+
}
|
|
44
|
+
const cookie2 = given.get('set-cookie2');
|
|
45
|
+
if (cookie2 !== null) headers.set('set-cookie2', cookie2);
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The headers an init names, read once (an iterable may be a generator), as a guard-free `Headers`. */
|
|
50
|
+
function givenHeaders(init: ResponseInit | undefined): Headers | undefined {
|
|
51
|
+
const headers = init?.headers;
|
|
52
|
+
return headers === undefined || headers === null ? undefined : new Headers(headers);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The init with its headers replaced, read as the platform reads a
|
|
57
|
+
* dictionary: each member by name, inherited ones too (a `Response` passed as
|
|
58
|
+
* the init, as Next's `NextResponse` does, carries its status on a getter of
|
|
59
|
+
* its prototype, which a spread would not copy).
|
|
60
|
+
*/
|
|
61
|
+
function withHeaders(init: ResponseInit | undefined, headers: Headers): ResponseInit {
|
|
62
|
+
const status = init?.status;
|
|
63
|
+
const statusText = init?.statusText;
|
|
64
|
+
return {
|
|
65
|
+
headers,
|
|
66
|
+
...(status === undefined ? {} : { status }),
|
|
67
|
+
...(statusText === undefined ? {} : { statusText }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install the corrected constructor on `target` (the realm's global object),
|
|
73
|
+
* over the `Response` it has. Every property it changes is remembered for the
|
|
74
|
+
* host to have back (`restoreHostGlobals`); a realm that has it already is left.
|
|
75
|
+
*/
|
|
76
|
+
export function installNodeResponse(target: { Response?: typeof Response }): void {
|
|
77
|
+
const Native = target.Response as ResponseConstructor | undefined;
|
|
78
|
+
if (typeof Native !== 'function' || Native[INSTALLED]) return;
|
|
79
|
+
const proto = Native.prototype;
|
|
80
|
+
// The getter is the prototype's own in a browser; found up the chain wherever it is.
|
|
81
|
+
let nativeHeaders: (() => unknown) | undefined;
|
|
82
|
+
for (let at: object | null = proto; at && !nativeHeaders; at = Object.getPrototypeOf(at)) nativeHeaders = Object.getOwnPropertyDescriptor(at, 'headers')?.get;
|
|
83
|
+
const nativeFormData = proto.formData;
|
|
84
|
+
const nativeBlob = proto.blob;
|
|
85
|
+
const nativeArrayBuffer = proto.arrayBuffer;
|
|
86
|
+
const nativeClone = proto.clone;
|
|
87
|
+
if (!nativeHeaders) return;
|
|
88
|
+
const kept = new WeakMap<object, Headers>();
|
|
89
|
+
|
|
90
|
+
const NodeResponse = function Response(this: unknown, body?: BodyInit | null, init?: ResponseInit): Response {
|
|
91
|
+
if (!new.target) throw new TypeError("Class constructor Response cannot be invoked without 'new'");
|
|
92
|
+
const given = givenHeaders(init);
|
|
93
|
+
const response = Reflect.construct(Native, [nodeResponseBody(body) as BodyInit | null | undefined, given ? withHeaders(init, given) : init], (new.target as unknown) === NodeResponse ? Native : new.target) as Response;
|
|
94
|
+
kept.set(response, withCookies(nativeHeaders.call(response) as Headers, given));
|
|
95
|
+
return response;
|
|
96
|
+
} as unknown as ResponseConstructor;
|
|
97
|
+
NodeResponse.prototype = proto;
|
|
98
|
+
Object.defineProperty(NodeResponse, 'length', { value: 0, configurable: true });
|
|
99
|
+
Object.defineProperty(NodeResponse, INSTALLED, { value: true });
|
|
100
|
+
Object.setPrototypeOf(NodeResponse, Object.getPrototypeOf(Native));
|
|
101
|
+
|
|
102
|
+
// Node's statics answer a response whose headers are as mutable as a
|
|
103
|
+
// constructed one's (json) or immutable (redirect, error), as here.
|
|
104
|
+
Object.defineProperty(NodeResponse, 'json', {
|
|
105
|
+
value: function json(data: unknown, init?: ResponseInit): Response {
|
|
106
|
+
const given = givenHeaders(init);
|
|
107
|
+
const response = Native.json(data, given ? withHeaders(init, given) : init);
|
|
108
|
+
kept.set(response, withCookies(nativeHeaders.call(response) as Headers, given));
|
|
109
|
+
return response;
|
|
110
|
+
},
|
|
111
|
+
writable: true, configurable: true,
|
|
112
|
+
});
|
|
113
|
+
Object.defineProperty(NodeResponse, 'redirect', { value: function redirect(url: string | URL, status?: number): Response { return Native.redirect(url, status); }, writable: true, configurable: true });
|
|
114
|
+
Object.defineProperty(NodeResponse, 'error', { value: function error(): Response { return Native.error(); }, writable: true, configurable: true });
|
|
115
|
+
|
|
116
|
+
defineOnHost(proto, 'headers', {
|
|
117
|
+
get(this: Response): Headers { return kept.get(this) ?? (nativeHeaders.call(this) as Headers); },
|
|
118
|
+
enumerable: true, configurable: true,
|
|
119
|
+
});
|
|
120
|
+
defineOnHost(proto, 'constructor', { value: NodeResponse, writable: true, enumerable: false, configurable: true });
|
|
121
|
+
|
|
122
|
+
// A clone's headers are a copy of the original's, cookies and all.
|
|
123
|
+
defineOnHost(proto, 'clone', {
|
|
124
|
+
value: function clone(this: Response): Response {
|
|
125
|
+
const copy = nativeClone.call(this);
|
|
126
|
+
const headers = kept.get(this);
|
|
127
|
+
if (headers) kept.set(copy, new Headers(headers));
|
|
128
|
+
return copy;
|
|
129
|
+
},
|
|
130
|
+
writable: true, enumerable: true, configurable: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** The kept content type where it is no longer the one the browser's body was built with. */
|
|
134
|
+
const changedType = (response: Response): string | null | undefined => {
|
|
135
|
+
const headers = kept.get(response);
|
|
136
|
+
if (!headers) return undefined;
|
|
137
|
+
const current = headers.get('content-type');
|
|
138
|
+
return current === (nativeHeaders.call(response) as Headers).get('content-type') ? undefined : current;
|
|
139
|
+
};
|
|
140
|
+
defineOnHost(proto, 'formData', {
|
|
141
|
+
value: async function formData(this: Response): Promise<FormData> {
|
|
142
|
+
const type = changedType(this);
|
|
143
|
+
if (type === undefined) return nativeFormData.call(this);
|
|
144
|
+
const bytes = await nativeArrayBuffer.call(this);
|
|
145
|
+
return nativeFormData.call(Reflect.construct(Native, [bytes, type === null ? {} : { headers: { 'content-type': type } }]) as Response);
|
|
146
|
+
},
|
|
147
|
+
writable: true, enumerable: true, configurable: true,
|
|
148
|
+
});
|
|
149
|
+
defineOnHost(proto, 'blob', {
|
|
150
|
+
value: async function blob(this: Response): Promise<Blob> {
|
|
151
|
+
const type = changedType(this);
|
|
152
|
+
if (type === undefined) return nativeBlob.call(this);
|
|
153
|
+
return new Blob([await nativeArrayBuffer.call(this)], type === null ? {} : { type });
|
|
154
|
+
},
|
|
155
|
+
writable: true, enumerable: true, configurable: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
takeFromHost(target, 'Response', NodeResponse);
|
|
159
|
+
}
|
|
160
|
+
|
package/src/runtime.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { VirtualFS } from './virtual-fs';
|
|
9
9
|
import { guestPromise, intrinsicPromise } from './promise-ownership';
|
|
10
10
|
import { guestFetch, rememberRequestBodySource } from './fetch-transport';
|
|
11
|
+
import { installNodeResponse } from './node-response';
|
|
12
|
+
import { withNodeRequestBody } from './node-body';
|
|
11
13
|
import { forGuestRealm, installGuestRealm, takeFromHost, defineOnHost, heldWork } from './host-globals';
|
|
12
14
|
import type { IRuntime, IExecuteResult, IRuntimeOptions } from './runtime-interface';
|
|
13
15
|
import type { PackageJson } from './types/package-json';
|
|
@@ -2952,6 +2954,8 @@ forGuestRealm(() => {
|
|
|
2952
2954
|
const kept = new WeakMap<object, Headers>();
|
|
2953
2955
|
class NodeRequest extends Native {
|
|
2954
2956
|
constructor(input: RequestInfo | URL, init?: RequestInit) {
|
|
2957
|
+
// An async-iterable body (a Node stream) is read as undici reads it.
|
|
2958
|
+
init = withNodeRequestBody(init);
|
|
2955
2959
|
super(input as RequestInfo, init);
|
|
2956
2960
|
rememberRequestBodySource(this, input, init);
|
|
2957
2961
|
const asked = new Headers(init?.headers ?? (input instanceof Native ? (input as Request).headers : undefined));
|
|
@@ -2974,6 +2978,12 @@ forGuestRealm(() => {
|
|
|
2974
2978
|
}
|
|
2975
2979
|
});
|
|
2976
2980
|
|
|
2981
|
+
forGuestRealm(() => {
|
|
2982
|
+
// A response keeps the Set-Cookie headers it was built with, as Node's
|
|
2983
|
+
// does; the browser's response guard drops them (see node-response.ts).
|
|
2984
|
+
installNodeResponse(globalThis);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2977
2987
|
forGuestRealm(() => {
|
|
2978
2988
|
// Node's `stream.finished()` on a web stream waits on a promise Node's own
|
|
2979
2989
|
// web streams carry (`nodejs.webstream.isClosedPromise`), settled when the
|
|
@@ -268,6 +268,22 @@ export function registerRunStreams(token: ProcessToken, streams: RunStreams): vo
|
|
|
268
268
|
/** Forget a run that has ended. */
|
|
269
269
|
export function releaseRunStreams(token: ProcessToken): void {
|
|
270
270
|
_runStreams.delete(token);
|
|
271
|
+
shellRuns.delete(token);
|
|
272
|
+
hostedNodes.delete(token);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Runs whose program is a shell or another non-node program: a `node` they run is their child. */
|
|
276
|
+
const shellRuns = new Set<ProcessToken>();
|
|
277
|
+
/** Runs that are themselves a hosted `node`: a further `node` under them is a child. */
|
|
278
|
+
const hostedNodes = new Set<ProcessToken>();
|
|
279
|
+
|
|
280
|
+
/** A child identity for a `node` a run's shell runs: its own token and pid under the run's, on the run's streams. */
|
|
281
|
+
function nestedNodeToken(parentToken: ProcessToken, args: readonly string[], cwd: string | undefined): ProcessToken {
|
|
282
|
+
const token: ProcessToken = `child-${__nextChildRun++}`;
|
|
283
|
+
setRunPid(token, mintPid(), runPid(parentToken)?.pid ?? 0, { argv: ['node', ...args], ...(cwd ? { cwd } : {}) });
|
|
284
|
+
const parentStreams = runStreamsFor(parentToken);
|
|
285
|
+
if (parentStreams) registerRunStreams(token, parentStreams);
|
|
286
|
+
return token;
|
|
271
287
|
}
|
|
272
288
|
|
|
273
289
|
/** What the host gave the named run, while it lasts. */
|
|
@@ -429,14 +445,30 @@ export function initChildProcess(vfs: VirtualFS): void {
|
|
|
429
445
|
|
|
430
446
|
// Capture this run's name, streams and cancellation before another Node
|
|
431
447
|
// entry can start. Forks and shell entries reach this same dispatch seam.
|
|
432
|
-
const
|
|
448
|
+
const parentToken = runTokenOf(ctx);
|
|
449
|
+
// A `node` a shell runs is a process of its own unless it is the run
|
|
450
|
+
// itself: the first `node` of a run that is not a shell's child is the
|
|
451
|
+
// run (the CMD `node server.js`, the page's `node -e`), and every other
|
|
452
|
+
// one, a line of a start script, the second command of a `-c` line, a
|
|
453
|
+
// step of a `sh file` child, gets a child token under the run's pid,
|
|
454
|
+
// the run's streams, and its own admission at the host. Admitted under
|
|
455
|
+
// the shell's own token, its exit was read as the shell's: a script
|
|
456
|
+
// ended at its first `node` line with nothing after it (measured
|
|
457
|
+
// 2026-09-28: `sh start.sh` printed its first step and stopped).
|
|
458
|
+
const nested = parentToken !== null && (shellRuns.has(parentToken) || hostedNodes.has(parentToken));
|
|
459
|
+
const runToken = nested ? nestedNodeToken(parentToken!, args, ctx.cwd) : parentToken;
|
|
433
460
|
const streams = runToken === null ? undefined : runStreamsFor(runToken);
|
|
434
461
|
const processHost = nodeProcessHostFor(runToken);
|
|
435
462
|
if (processHost && runToken !== null) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
463
|
+
if (!nested) hostedNodes.add(runToken);
|
|
464
|
+
try {
|
|
465
|
+
return await runHostedNode(processHost, {
|
|
466
|
+
token: runToken, argv: [...args], cwd: ctx.cwd, filesystem: tree, env: guestEnvironmentOf(ctx),
|
|
467
|
+
...(typeof ctx.stdin === 'string' ? { stdin: ctx.stdin } : {}), ...(streams ? { streams } : {}),
|
|
468
|
+
});
|
|
469
|
+
} finally {
|
|
470
|
+
if (nested) releaseRunStreams(runToken);
|
|
471
|
+
}
|
|
440
472
|
}
|
|
441
473
|
|
|
442
474
|
// Node reads its own options before the script: `node --turbo-fast-api-calls
|
|
@@ -1719,6 +1751,9 @@ function existsInTree(path: string): boolean {
|
|
|
1719
1751
|
* a program the engine can run; the engine's own `node`, written to
|
|
1720
1752
|
* `/usr/local/bin/node` when the shim is initialized, is one of those.
|
|
1721
1753
|
*/
|
|
1754
|
+
/** The shells a script or a `-c` line runs under; a child of these goes to the host when there is one. */
|
|
1755
|
+
const SHELL_PROGRAMS = new Set(['sh', 'bash', 'dash', '/bin/sh', '/bin/bash', '/usr/bin/sh', '/usr/bin/bash']);
|
|
1756
|
+
|
|
1722
1757
|
function engineProgramFor(file: string, cwd?: string): string {
|
|
1723
1758
|
const name = __substrateProgramName(file);
|
|
1724
1759
|
if (!name.includes('/')) return name;
|
|
@@ -1794,6 +1829,7 @@ function startChildRun(request: RunRequest): StartedRun {
|
|
|
1794
1829
|
// A host terminal consumes input incrementally. The old string-only
|
|
1795
1830
|
// route dropped every keystroke that arrived after a child was launched.
|
|
1796
1831
|
const admittedNode = nodeProcessHostInstalled() && engineProgramFor(request.file, request.cwd) === 'node';
|
|
1832
|
+
if (!admittedNode) shellRuns.add(token);
|
|
1797
1833
|
const hostTerminal = request.terminal !== undefined && hostExecutor() !== null && !admittedNode;
|
|
1798
1834
|
let wakeInput: (() => void) | undefined;
|
|
1799
1835
|
/**
|
|
@@ -1904,7 +1940,14 @@ function startChildRun(request: RunRequest): StartedRun {
|
|
|
1904
1940
|
const begin = (): void => {
|
|
1905
1941
|
if (started || finished) return;
|
|
1906
1942
|
started = true;
|
|
1907
|
-
|
|
1943
|
+
// A shell a hosted program spawns (`sh start.sh`, `sh -c '…'`) runs at
|
|
1944
|
+
// the host when there is one, not in this worker's own bash: the host is
|
|
1945
|
+
// where the programs the page registered live (`npx` under /usr/bin),
|
|
1946
|
+
// and where a `node` inside the script is given a named run. Run here,
|
|
1947
|
+
// the script's `node` found no name and was refused, and its `npx` was
|
|
1948
|
+
// not found at all.
|
|
1949
|
+
const shellChild = SHELL_PROGRAMS.has(engineProgramFor(request.file, request.cwd)) && hostExecutor() !== null;
|
|
1950
|
+
const engineFirst = !hostTerminal && !shellChild && programExists(request.file, request.cwd, request.env);
|
|
1908
1951
|
liveInput = !hostTerminal && !admittedNode && request.stdinIsPipe && !engineFirst;
|
|
1909
1952
|
if (liveInput) {
|
|
1910
1953
|
pendingStdin.unshift(...initialStdin);
|
package/src/shims/shell-jobs.ts
CHANGED
|
@@ -386,6 +386,78 @@ export function installShellJobs(bash: Bash, host: ShellJobsHost): void {
|
|
|
386
386
|
return { stdout: held.stdout, stderr: held.stderr + stderr, exitCode: status };
|
|
387
387
|
}));
|
|
388
388
|
|
|
389
|
+
// `sh file`, `sh -c line` and `sh` on stdin: the shell's own ran the script
|
|
390
|
+
// with the caller's exported variables alone, and a run's name travels in
|
|
391
|
+
// a variable the shell never exports, so a `node` on any line of a script
|
|
392
|
+
// found no named run and was refused ("Isolated Node execution requires a
|
|
393
|
+
// named run", a start script in browser-substrate's tab, 2026-09-28). The
|
|
394
|
+
// script is run as the shell's own would run it, with the name carried.
|
|
395
|
+
for (const shell of ['sh', 'bash'] as const) {
|
|
396
|
+
bash.registerCommand(defineCommand(shell, async (args, ctx) => {
|
|
397
|
+
const exec = ctx.exec;
|
|
398
|
+
if (!exec) return { stdout: '', stderr: `bash: ${shell}: needs a shell that can run a script\n`, exitCode: 1 };
|
|
399
|
+
let script: string;
|
|
400
|
+
let zero: string;
|
|
401
|
+
let positional: string[];
|
|
402
|
+
let sourced: string | undefined;
|
|
403
|
+
// Options before the script (`sh -e start.sh`, `bash -eu -c …`) are the
|
|
404
|
+
// shell's own; they are passed over rather than read as the file's name.
|
|
405
|
+
let at = 0;
|
|
406
|
+
while (at < args.length && /^[-+][a-bd-zA-Z]+$/u.test(args[at]!)) at++;
|
|
407
|
+
args = args.slice(at);
|
|
408
|
+
if (args[0] === '-c' && args.length >= 2) {
|
|
409
|
+
script = args[1]!;
|
|
410
|
+
zero = args[2] ?? shell;
|
|
411
|
+
positional = args.slice(3);
|
|
412
|
+
} else if (args.length === 0) {
|
|
413
|
+
if (!ctx.stdin?.trim()) return { stdout: '', stderr: '', exitCode: 0 };
|
|
414
|
+
script = ctx.stdin;
|
|
415
|
+
zero = shell;
|
|
416
|
+
positional = [];
|
|
417
|
+
} else {
|
|
418
|
+
const path = ctx.fs.resolvePath(ctx.cwd, args[0]!);
|
|
419
|
+
try { script = String(await ctx.fs.readFile(path)); }
|
|
420
|
+
catch { return { stdout: '', stderr: `${shell}: ${args[0]}: No such file or directory\n`, exitCode: 127 }; }
|
|
421
|
+
zero = args[0]!;
|
|
422
|
+
positional = args.slice(1);
|
|
423
|
+
sourced = path;
|
|
424
|
+
}
|
|
425
|
+
if (script.startsWith('#!')) {
|
|
426
|
+
const newline = script.indexOf('\n');
|
|
427
|
+
if (newline !== -1) script = script.slice(newline + 1);
|
|
428
|
+
}
|
|
429
|
+
const env: Record<string, string> = { ...(ctx.exportedEnv ?? {}), '0': zero, '#': String(positional.length), '@': positional.join(' '), '*': positional.join(' ') };
|
|
430
|
+
positional.forEach((word, index) => { env[String(index + 1)] = word; });
|
|
431
|
+
const token = ctx.env.get(PROCESS_TOKEN_ENV);
|
|
432
|
+
if (token) env[PROCESS_TOKEN_ENV] = token;
|
|
433
|
+
// A file is sourced by one statement of the nested run rather than run
|
|
434
|
+
// as the nested run's own script: a nested script of several lines
|
|
435
|
+
// stopped after its first hosted `node` in a tab (2026-09-28), while
|
|
436
|
+
// the same lines sourced from one statement ran whole. The shebang
|
|
437
|
+
// line is a comment to the shell that sources it.
|
|
438
|
+
if (sourced !== undefined) script = `. ${sourced.replace(/'/gu, "'\\''").replace(/^(.*)$/su, "'$1'")}`;
|
|
439
|
+
const result = await exec(script, { env, cwd: ctx.cwd, stdin: ctx.stdin, signal: (ctx as { signal?: AbortSignal }).signal });
|
|
440
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: result.exitCode ?? 0 };
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
// `exec command…` runs the command in the script's place, with the
|
|
444
|
+
// script's environment and its run's name, and answers with its status.
|
|
445
|
+
// The shell's own `exec` ran a builtin but a program the engine defines,
|
|
446
|
+
// `node`, ran nothing: a start script's `exec node server.js` ended with
|
|
447
|
+
// no server and no word. This one hands the line back to the shell, which
|
|
448
|
+
// is where `node`, `npm` and the host's programs are found. The shell is
|
|
449
|
+
// not replaced: a line after `exec` runs once the command ends, where
|
|
450
|
+
// bash would never reach it. A start script's `exec` is its last line, a
|
|
451
|
+
// server that does not end.
|
|
452
|
+
bash.registerCommand(defineCommand('exec', async (args, ctx) => {
|
|
453
|
+
if (args.length === 0) return { stdout: '', stderr: '', exitCode: 0 };
|
|
454
|
+
if (!ctx.exec) return { stdout: '', stderr: 'bash: exec: needs a shell that can run a line\n', exitCode: 1 };
|
|
455
|
+
const line = args.map((word) => /^[A-Za-z0-9_./:=@%+,-]+$/u.test(word) ? word : `'${word.replace(/'/gu, "'\\''")}'`).join(' ');
|
|
456
|
+
const env: Record<string, string> = Object.fromEntries(ctx.env);
|
|
457
|
+
const result = await ctx.exec(line, { env, cwd: ctx.cwd, replaceEnv: true });
|
|
458
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: result.exitCode ?? 0 };
|
|
459
|
+
}));
|
|
460
|
+
|
|
389
461
|
bash.registerCommand(defineCommand('kill', async (args) => {
|
|
390
462
|
let signal = 'SIGTERM';
|
|
391
463
|
let index = 0;
|