@volter/browser-toolbelt 0.1.5 → 0.1.6
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 +15 -0
- package/dist/browser-git-http.d.ts +28 -2
- package/dist/browser-git-http.d.ts.map +1 -1
- package/dist/browser-git-http.js +5 -1
- package/dist/browser-git-http.js.map +1 -1
- package/dist/browser-git-remote-https.d.ts +34 -0
- package/dist/browser-git-remote-https.d.ts.map +1 -0
- package/dist/browser-git-remote-https.js +619 -0
- package/dist/browser-git-remote-https.js.map +1 -0
- package/dist/browser-interactive-shell.d.ts +11 -0
- package/dist/browser-interactive-shell.d.ts.map +1 -0
- package/dist/browser-interactive-shell.js +211 -0
- package/dist/browser-interactive-shell.js.map +1 -0
- package/dist/browser-rm.d.ts +3 -0
- package/dist/browser-rm.d.ts.map +1 -0
- package/dist/browser-rm.js +61 -0
- package/dist/browser-rm.js.map +1 -0
- package/dist/browser-shell-filesystem.d.ts +1 -1
- package/dist/browser-shell-filesystem.d.ts.map +1 -1
- package/dist/browser-shell-filesystem.js +25 -7
- package/dist/browser-shell-filesystem.js.map +1 -1
- package/dist/browser-shell.d.ts +52 -3
- package/dist/browser-shell.d.ts.map +1 -1
- package/dist/browser-shell.js +387 -58
- package/dist/browser-shell.js.map +1 -1
- package/dist/github-device-auth.d.ts +23 -2
- package/dist/github-device-auth.d.ts.map +1 -1
- package/dist/github-device-auth.js +132 -17
- package/dist/github-device-auth.js.map +1 -1
- package/dist/github-web-sign-in.d.ts +24 -0
- package/dist/github-web-sign-in.d.ts.map +1 -0
- package/dist/github-web-sign-in.js +93 -0
- package/dist/github-web-sign-in.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +8 -3
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +37 -5
- package/dist/install.js.map +1 -1
- package/dist/shell-buffer-global.d.ts +2 -0
- package/dist/shell-buffer-global.d.ts.map +1 -0
- package/dist/shell-buffer-global.js +10 -0
- package/dist/shell-buffer-global.js.map +1 -0
- package/dist/volter-sign-in.d.ts +62 -0
- package/dist/volter-sign-in.d.ts.map +1 -0
- package/dist/volter-sign-in.js +255 -0
- package/dist/volter-sign-in.js.map +1 -0
- package/package.json +22 -10
- package/dist/browser-git-filesystem.d.ts +0 -18
- package/dist/browser-git-filesystem.d.ts.map +0 -1
- package/dist/browser-git-filesystem.js +0 -84
- package/dist/browser-git-filesystem.js.map +0 -1
- package/dist/browser-git.d.ts +0 -38
- package/dist/browser-git.d.ts.map +0 -1
- package/dist/browser-git.js +0 -514
- package/dist/browser-git.js.map +0 -1
package/dist/browser-shell.js
CHANGED
|
@@ -1,60 +1,158 @@
|
|
|
1
|
+
import "./shell-buffer-global.js";
|
|
1
2
|
import { Bash, defineCommand } from "just-bash/browser";
|
|
3
|
+
import { commandNotFound } from "@volter/browser-runtime/browser-process-host.js";
|
|
4
|
+
import { confineBrowserFileSystem, intersectProcessFilesystemPolicies, normalizeProcessFilesystemPolicy, requireProcessFilesystemPolicySupport, } from "@volter/browser-runtime/browser-process-filesystem-policy.js";
|
|
2
5
|
import { BrowserShellFileSystem } from "./browser-shell-filesystem.js";
|
|
6
|
+
import { browserRmCommand } from "./browser-rm.js";
|
|
3
7
|
/** AST Bash, standard toolbelt, and a small persistent browser job table. */
|
|
4
8
|
export class BrowserShell {
|
|
5
|
-
|
|
9
|
+
commands = new Map();
|
|
6
10
|
virtualFilesystem;
|
|
7
11
|
filesystem;
|
|
8
12
|
jobs = new Map();
|
|
9
13
|
registeredPrograms = new Set();
|
|
14
|
+
programBindings = new Map();
|
|
15
|
+
confinedShells = new Set();
|
|
16
|
+
capabilities = Object.freeze({ filesystemPolicy: true });
|
|
10
17
|
streamingRuns = new WeakMap();
|
|
18
|
+
resolvers = [];
|
|
11
19
|
nextPid = 1000;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
/** `$!`: the job this shell last started; each command runs in a fresh interpreter, so the shell carries it. */
|
|
21
|
+
lastBackgroundPid = 0;
|
|
22
|
+
/**
|
|
23
|
+
* The container's processes, as Docker's `top` lists them: `kill` reaches
|
|
24
|
+
* a pid there that is not one of this shell's jobs, as a machine's kill
|
|
25
|
+
* reaches any process of its user. A confined shell has none.
|
|
26
|
+
*/
|
|
27
|
+
processTable;
|
|
28
|
+
fixedPolicy;
|
|
29
|
+
constructor(filesystem, policy) {
|
|
30
|
+
this.fixedPolicy = policy === undefined ? undefined : normalizeProcessFilesystemPolicy(policy);
|
|
31
|
+
this.virtualFilesystem = this.fixedPolicy ? confineBrowserFileSystem(filesystem, this.fixedPolicy) : filesystem;
|
|
32
|
+
this.filesystem = new BrowserShellFileSystem(this.virtualFilesystem);
|
|
15
33
|
this.ensureRuntimeDirectories();
|
|
16
|
-
this.
|
|
34
|
+
this.registerCommand(browserRmCommand);
|
|
35
|
+
this.registerShellExecutables();
|
|
36
|
+
this.registerJobCommands();
|
|
37
|
+
this.createBash();
|
|
38
|
+
}
|
|
39
|
+
createBash(env) {
|
|
40
|
+
return new Bash({
|
|
17
41
|
fs: this.filesystem,
|
|
18
42
|
cwd: "/",
|
|
19
43
|
env: {
|
|
20
|
-
HOME: "/home/
|
|
21
|
-
USER: "
|
|
44
|
+
HOME: "/home/user",
|
|
45
|
+
USER: "user",
|
|
22
46
|
PATH: "/usr/local/bin:/usr/bin:/bin:/node_modules/.bin",
|
|
23
47
|
NODE_ENV: "development",
|
|
48
|
+
...env,
|
|
24
49
|
},
|
|
25
50
|
processInfo: { pid: 1, ppid: 0, uid: 1000, gid: 1000 },
|
|
51
|
+
customCommands: [...this.commands.values()],
|
|
26
52
|
});
|
|
27
|
-
this.registerShellExecutables();
|
|
28
|
-
this.registerJobCommands();
|
|
29
53
|
}
|
|
30
|
-
|
|
54
|
+
registerCommand(command) {
|
|
55
|
+
this.commands.set(command.name, command);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A word of a command line that no program claims may still be a command
|
|
59
|
+
* where it is typed, one a lane's ecosystem installed into a directory tree.
|
|
60
|
+
* Each resolver is asked with that directory; the first answer is registered
|
|
61
|
+
* as a program, whose runner resolves again at every call.
|
|
62
|
+
*/
|
|
63
|
+
registerCommandResolver(resolver) {
|
|
64
|
+
this.resolvers.push(resolver);
|
|
65
|
+
}
|
|
66
|
+
resolveUnknownCommands(command, cwd) {
|
|
67
|
+
if (this.resolvers.length === 0)
|
|
68
|
+
return;
|
|
69
|
+
// Every identifier-shaped word of the line, operators and quotes aside: a
|
|
70
|
+
// name that resolves is a command; one that does not costs one lookup.
|
|
71
|
+
const words = command.match(/[A-Za-z0-9][\w.-]*/gu) ?? [];
|
|
72
|
+
for (const word of new Set(words)) {
|
|
73
|
+
if (this.registeredPrograms.has(word))
|
|
74
|
+
continue;
|
|
75
|
+
const resolved = this.resolvers.some((resolver) => resolver(word, cwd) !== undefined);
|
|
76
|
+
if (!resolved)
|
|
77
|
+
continue;
|
|
78
|
+
const name = word;
|
|
79
|
+
this.registerProgram(name, (argv, options) => {
|
|
80
|
+
const runner = this.resolvers.map((resolver) => resolver(name, options.cwd ?? cwd)).find(Boolean);
|
|
81
|
+
if (!runner)
|
|
82
|
+
return Promise.resolve({ stdout: "", stderr: `bash: ${commandNotFound(name)}`, exitCode: 127 });
|
|
83
|
+
return runner(argv, options);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
registerProgram(name, runner, capabilities = {}) {
|
|
31
88
|
this.registeredPrograms.add(name);
|
|
32
|
-
|
|
89
|
+
const declared = Object.freeze({ ...capabilities });
|
|
90
|
+
this.programBindings.set(name, { runner, capabilities: declared });
|
|
91
|
+
this.registerCommand(trustedCommand(name, async (args, context) => {
|
|
92
|
+
requireProcessFilesystemPolicySupport(this.fixedPolicy, declared);
|
|
33
93
|
const streaming = context.signal ? this.streamingRuns.get(context.signal) : undefined;
|
|
34
94
|
let streamedStdout = false;
|
|
35
95
|
let streamedStderr = false;
|
|
96
|
+
// Where nothing streams this command onward (a pipe, a substitution),
|
|
97
|
+
// what the program streamed is its output: a program that only
|
|
98
|
+
// streams (git's remote helper) returns none of it in its result.
|
|
99
|
+
let collectedStdout = "";
|
|
100
|
+
let collectedStderr = "";
|
|
36
101
|
const result = await runner([name, ...args], {
|
|
37
102
|
cwd: context.cwd,
|
|
38
|
-
|
|
103
|
+
...(this.fixedPolicy ? { filesystemPolicy: this.fixedPolicy } : {}),
|
|
104
|
+
env: programEnvironment(context.exportedEnv),
|
|
39
105
|
stdin: context.stdin,
|
|
40
106
|
...(context.signal ? { signal: context.signal } : {}),
|
|
41
|
-
|
|
42
|
-
|
|
107
|
+
onStdout: (data) => {
|
|
108
|
+
streamedStdout = true;
|
|
109
|
+
if (streaming?.onStdout)
|
|
43
110
|
streaming.onStdout(data);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
111
|
+
else
|
|
112
|
+
collectedStdout += data;
|
|
113
|
+
},
|
|
114
|
+
onStderr: (data) => {
|
|
115
|
+
streamedStderr = true;
|
|
116
|
+
if (streaming?.onStderr)
|
|
47
117
|
streaming.onStderr(data);
|
|
48
|
-
|
|
118
|
+
else
|
|
119
|
+
collectedStderr += data;
|
|
120
|
+
},
|
|
49
121
|
});
|
|
50
122
|
return {
|
|
51
123
|
...result,
|
|
52
|
-
stdout: streamedStdout ?
|
|
53
|
-
stderr: streamedStderr ?
|
|
124
|
+
stdout: streamedStdout ? collectedStdout : result.stdout,
|
|
125
|
+
stderr: streamedStderr ? collectedStderr : result.stderr,
|
|
54
126
|
};
|
|
55
127
|
}));
|
|
56
128
|
}
|
|
57
129
|
async run(command, options = {}) {
|
|
130
|
+
if (options.filesystemPolicy !== undefined || this.fixedPolicy !== undefined) {
|
|
131
|
+
const policy = this.fixedPolicy
|
|
132
|
+
? intersectProcessFilesystemPolicies(this.fixedPolicy, options.filesystemPolicy)
|
|
133
|
+
: normalizeProcessFilesystemPolicy(options.filesystemPolicy);
|
|
134
|
+
if (!this.fixedPolicy || JSON.stringify(policy) !== JSON.stringify(this.fixedPolicy)) {
|
|
135
|
+
// One immutable view per invocation: overlapping commands, nested
|
|
136
|
+
// shells and detached jobs must never borrow another run's authority.
|
|
137
|
+
const child = new BrowserShell(this.virtualFilesystem, policy);
|
|
138
|
+
for (const [name, binding] of this.programBindings)
|
|
139
|
+
child.registerProgram(name, binding.runner, binding.capabilities);
|
|
140
|
+
for (const resolver of this.resolvers)
|
|
141
|
+
child.registerCommandResolver(resolver);
|
|
142
|
+
this.confinedShells.add(child);
|
|
143
|
+
try {
|
|
144
|
+
return await child.run(command, { ...options, filesystemPolicy: policy });
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
const jobs = [...child.jobs.values()].filter((job) => job.state === "running");
|
|
148
|
+
if (jobs.length === 0)
|
|
149
|
+
this.confinedShells.delete(child);
|
|
150
|
+
else
|
|
151
|
+
void Promise.allSettled(jobs.map((job) => job.promise)).then(() => this.confinedShells.delete(child));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
options = { ...options, filesystemPolicy: policy };
|
|
155
|
+
}
|
|
58
156
|
// Project restoration replaces the VFS contents after the shell is created.
|
|
59
157
|
// Reassert process-owned directories at the command boundary so restores and
|
|
60
158
|
// project resets cannot invalidate the shell environment.
|
|
@@ -64,15 +162,25 @@ export class BrowserShell {
|
|
|
64
162
|
return changedDirectory;
|
|
65
163
|
const sequence = splitBackgroundSequence(command);
|
|
66
164
|
if (sequence) {
|
|
67
|
-
const
|
|
165
|
+
const first = sequence.before ? await this.run(sequence.before, options) : undefined;
|
|
166
|
+
// what the list before it did to the shell (a cd, an export) the job starts in
|
|
167
|
+
const after = first
|
|
168
|
+
? { ...options, ...(first.cwd ? { cwd: first.cwd } : {}), ...(first.env ? { env: first.env } : {}) }
|
|
169
|
+
: options;
|
|
170
|
+
// A job and what follows it run in interpreters of their own; the
|
|
171
|
+
// functions the list defined go with them, as a subshell inherits its
|
|
172
|
+
// parent's (`f() { …; }; f &`). Variables and the directory go as `after`.
|
|
173
|
+
const defined = functionDefinitions(sequence.before);
|
|
174
|
+
const withFunctions = (text) => (defined ? `${defined}\n${text}` : text);
|
|
175
|
+
const background = this.startBackground(withFunctions(sequence.background), after);
|
|
68
176
|
// A background server commonly registers its port on a microtask. Let
|
|
69
177
|
// that startup settle before the foreground dev server claims its own
|
|
70
178
|
// port so process ownership remains deterministic.
|
|
71
179
|
await new Promise((resolve) => globalThis.setTimeout(resolve, 0));
|
|
72
|
-
const foreground = await this.run(sequence.foreground,
|
|
180
|
+
const foreground = await this.run(withFunctions(sequence.foreground), after);
|
|
73
181
|
return {
|
|
74
|
-
stdout: `${background.stdout}${foreground.stdout}`,
|
|
75
|
-
stderr: `${background.stderr}${foreground.stderr}`,
|
|
182
|
+
stdout: `${first?.stdout ?? ""}${background.stdout}${foreground.stdout}`,
|
|
183
|
+
stderr: `${first?.stderr ?? ""}${background.stderr}${foreground.stderr}`,
|
|
76
184
|
exitCode: foreground.exitCode,
|
|
77
185
|
...(foreground.cwd ? { cwd: foreground.cwd } : {}),
|
|
78
186
|
...(foreground.env ? { env: foreground.env } : {}),
|
|
@@ -84,13 +192,21 @@ export class BrowserShell {
|
|
|
84
192
|
return this.runForeground(command, options);
|
|
85
193
|
}
|
|
86
194
|
terminate() {
|
|
87
|
-
for (const
|
|
88
|
-
|
|
195
|
+
for (const shell of this.confinedShells)
|
|
196
|
+
shell.terminate();
|
|
197
|
+
this.confinedShells.clear();
|
|
198
|
+
for (const job of this.jobs.values()) {
|
|
199
|
+
// a job that survived a signal is ended now
|
|
200
|
+
if (job.controller.signal.aborted)
|
|
201
|
+
job.controller.signal.reason?.redeliver?.(9);
|
|
202
|
+
else
|
|
203
|
+
job.controller.abort(new DOMException("Shell terminated", "AbortError"));
|
|
204
|
+
}
|
|
89
205
|
this.jobs.clear();
|
|
90
206
|
}
|
|
91
207
|
ensureRuntimeDirectories() {
|
|
92
208
|
this.virtualFilesystem.mkdirSync("/tmp", { recursive: true });
|
|
93
|
-
this.virtualFilesystem.mkdirSync("/home/
|
|
209
|
+
this.virtualFilesystem.mkdirSync("/home/user", { recursive: true });
|
|
94
210
|
}
|
|
95
211
|
async changeDirectory(command, options) {
|
|
96
212
|
let words;
|
|
@@ -105,7 +221,7 @@ export class BrowserShell {
|
|
|
105
221
|
if (words.length > 2)
|
|
106
222
|
return { stdout: "", stderr: "bash: cd: too many arguments\n", exitCode: 1 };
|
|
107
223
|
const current = options.cwd ?? "/";
|
|
108
|
-
const requested = words[1] ?? options.env?.HOME ?? "/home/
|
|
224
|
+
const requested = words[1] ?? options.env?.HOME ?? "/home/user";
|
|
109
225
|
const destination = this.filesystem.resolvePath(current, requested);
|
|
110
226
|
try {
|
|
111
227
|
const value = await this.filesystem.stat(destination);
|
|
@@ -127,17 +243,26 @@ export class BrowserShell {
|
|
|
127
243
|
if (options.signal?.aborted)
|
|
128
244
|
return { stdout: "", stderr: "", exitCode: 130 };
|
|
129
245
|
const cwd = options.cwd ?? "/";
|
|
246
|
+
this.resolveUnknownCommands(command, cwd);
|
|
130
247
|
try {
|
|
131
|
-
|
|
248
|
+
// Just Bash exports constructor environment, but exec({env}) only adds
|
|
249
|
+
// shell variables and shares its export table. Each invocation needs its
|
|
250
|
+
// own interpreter so inherited variables export without crossing runs.
|
|
251
|
+
const bash = this.createBash(withNodeModuleBinPath(options.env, cwd));
|
|
252
|
+
seedLastBackgroundPid(bash, this.lastBackgroundPid);
|
|
253
|
+
const result = await bash.exec(withJobWait(command), {
|
|
132
254
|
cwd,
|
|
133
|
-
env: withNodeModuleBinPath(options.env, cwd),
|
|
134
255
|
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
|
|
135
256
|
...(options.signal ? { signal: options.signal } : {}),
|
|
136
257
|
rawScript: true,
|
|
137
258
|
});
|
|
259
|
+
const stderr = teachUnknownCommands(result.stderr);
|
|
138
260
|
options.onStdout?.(result.stdout);
|
|
139
|
-
options.onStderr?.(
|
|
140
|
-
|
|
261
|
+
options.onStderr?.(stderr);
|
|
262
|
+
// A `cd` inside a list (`cd /tmp; ls`) moves the shell as in any sh; the
|
|
263
|
+
// interpreter's PWD is where it ended, and a subshell's cd never leaks.
|
|
264
|
+
const moved = result.env.PWD && result.env.PWD !== (options.env?.PWD ?? cwd) ? result.env.PWD : undefined;
|
|
265
|
+
return { stdout: result.stdout, stderr, exitCode: result.exitCode, env: result.env, ...(moved ? { cwd: moved } : {}) };
|
|
141
266
|
}
|
|
142
267
|
catch (cause) {
|
|
143
268
|
if (options.signal?.aborted)
|
|
@@ -149,6 +274,7 @@ export class BrowserShell {
|
|
|
149
274
|
if (!command)
|
|
150
275
|
return { stdout: "", stderr: "bash: syntax error near unexpected token `&'\n", exitCode: 2 };
|
|
151
276
|
const pid = this.nextPid++;
|
|
277
|
+
this.lastBackgroundPid = pid;
|
|
152
278
|
const controller = new AbortController();
|
|
153
279
|
const abortWithParent = () => {
|
|
154
280
|
if (!controller.signal.aborted) {
|
|
@@ -166,23 +292,40 @@ export class BrowserShell {
|
|
|
166
292
|
state: "running",
|
|
167
293
|
promise: Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }),
|
|
168
294
|
};
|
|
295
|
+
// A job on a terminal writes to the terminal as it runs, past the line that
|
|
296
|
+
// started it; without one, what it printed is heard when it ends.
|
|
297
|
+
const device = options.terminal?.write;
|
|
298
|
+
const heard = device ? { ...options, onStdout: device, onStderr: device } : options;
|
|
169
299
|
const streamProgram = isSimpleCommand(command)
|
|
170
300
|
&& this.registeredPrograms.has(parseWords(command)[0] ?? "");
|
|
171
301
|
if (streamProgram)
|
|
172
|
-
this.streamingRuns.set(controller.signal,
|
|
302
|
+
this.streamingRuns.set(controller.signal, heard);
|
|
173
303
|
const { onStdout: _onStdout, onStderr: _onStderr, ...jobOptions } = options;
|
|
174
304
|
job.promise = this.runForeground(command, {
|
|
175
305
|
...jobOptions,
|
|
176
306
|
signal: controller.signal,
|
|
177
|
-
}).then((
|
|
307
|
+
}).then((ran) => {
|
|
178
308
|
options.signal?.removeEventListener("abort", abortWithParent);
|
|
309
|
+
// an interpreter stopped mid-script returns the last status it had; a
|
|
310
|
+
// job ended by a signal has 128 + that signal, as `wait` reports in bash;
|
|
311
|
+
// a program that handled the signal and exited reports its own status
|
|
312
|
+
const handled = controller.signal.reason?.handled === true;
|
|
313
|
+
const result = job.signal && !handled ? { ...ran, exitCode: 128 + job.signal } : ran;
|
|
179
314
|
job.state = "done";
|
|
180
315
|
job.result = result;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
316
|
+
// A registered program run directly may stream its output through the
|
|
317
|
+
// listeners it was handed, or return it, as the browser Git does. The
|
|
318
|
+
// binding blanks what was streamed, so what remains in the result is
|
|
319
|
+
// what nobody has seen yet, and it goes to the listeners now; a bare
|
|
320
|
+
// `git status` printed nothing while `git status; true` printed
|
|
321
|
+
// everything, because only the shell's path emitted a returned result
|
|
322
|
+
// (measured 2026-09-20 in the vscode example's shell, and the same
|
|
323
|
+
// path answers the extension host's `git` calls).
|
|
324
|
+
if (result.stdout)
|
|
325
|
+
heard.onStdout?.(result.stdout);
|
|
326
|
+
if (result.stderr)
|
|
327
|
+
heard.onStderr?.(result.stderr);
|
|
328
|
+
heard.onStderr?.(`[${pid}] ${job.signal && !handled ? SIGNAL_REPORTS[job.signal] ?? `Signal ${job.signal}` : `Done (${result.exitCode})`} ${command}\n`);
|
|
186
329
|
return result;
|
|
187
330
|
});
|
|
188
331
|
this.jobs.set(pid, job);
|
|
@@ -190,34 +333,79 @@ export class BrowserShell {
|
|
|
190
333
|
options.onStdout?.(stdout);
|
|
191
334
|
return { stdout, stderr: "", exitCode: 0 };
|
|
192
335
|
}
|
|
336
|
+
/** Gives `kill` the container's processes (the runtime's table). */
|
|
337
|
+
setProcessTable(table) {
|
|
338
|
+
this.processTable = table;
|
|
339
|
+
}
|
|
193
340
|
registerJobCommands() {
|
|
194
|
-
this.
|
|
341
|
+
this.registerCommand(trustedCommand("jobs", async () => ({
|
|
195
342
|
stdout: [...this.jobs.values()].map((job) => `[${job.pid}] ${job.state === "running" ? "Running" : "Done"} ${job.command}\n`).join(""),
|
|
196
343
|
stderr: "",
|
|
197
344
|
exitCode: 0,
|
|
198
345
|
})));
|
|
199
|
-
this.
|
|
200
|
-
|
|
346
|
+
this.registerCommand(trustedCommand("kill", async (args) => {
|
|
347
|
+
let signal = 15;
|
|
348
|
+
const targets = [];
|
|
349
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
350
|
+
const arg = args[index];
|
|
351
|
+
const named = arg === "-s" || arg === "-n" ? args[++index] : arg.startsWith("-") ? arg.slice(1) : undefined;
|
|
352
|
+
if (named === undefined) {
|
|
353
|
+
targets.push(arg);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const number = signalNumber(named);
|
|
357
|
+
if (number === undefined)
|
|
358
|
+
return { stdout: "", stderr: `kill: ${named}: invalid signal specification\n`, exitCode: 1 };
|
|
359
|
+
if (STOP_SIGNALS.has(number))
|
|
360
|
+
return { stdout: "", stderr: `kill: ${named}: this shell cannot stop a job, only end it\n`, exitCode: 1 };
|
|
361
|
+
signal = number;
|
|
362
|
+
}
|
|
201
363
|
if (targets.length === 0)
|
|
202
|
-
return { stdout: "", stderr: "kill: usage: kill
|
|
364
|
+
return { stdout: "", stderr: "kill: usage: kill [-s sigspec | -n signum | -sigspec] pid ...\n", exitCode: 2 };
|
|
203
365
|
let stderr = "";
|
|
204
366
|
for (const target of targets) {
|
|
205
367
|
const pid = Number(target.replace(/^%/u, ""));
|
|
206
368
|
const job = this.jobs.get(pid);
|
|
207
|
-
|
|
369
|
+
const process = job ? undefined : this.processTable?.().find((entry) => entry.pid === pid && entry.state === "running");
|
|
370
|
+
if (process) {
|
|
371
|
+
if (signal !== 0)
|
|
372
|
+
process.kill(Object.assign(new DOMException(SIGNAL_REPORTS[signal] ?? `Signal ${signal}`, "AbortError"), { signal }));
|
|
373
|
+
}
|
|
374
|
+
else if (!job || job.state !== "running")
|
|
208
375
|
stderr += `kill: (${target}) - No such process\n`;
|
|
209
|
-
|
|
210
|
-
|
|
376
|
+
// signal 0 asks only whether the job is there
|
|
377
|
+
else if (signal !== 0) {
|
|
378
|
+
job.signal = signal;
|
|
379
|
+
// A job that handled an earlier signal is still running: the next
|
|
380
|
+
// one reaches it through that abort's reason.
|
|
381
|
+
if (job.controller.signal.aborted) {
|
|
382
|
+
job.controller.signal.reason?.redeliver?.(signal);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// the number rides on the reason, so a program that handles the signal (a WALI git) gets it
|
|
386
|
+
job.controller.abort(Object.assign(new DOMException(SIGNAL_REPORTS[signal] ?? `Signal ${signal}`, "AbortError"), { signal }));
|
|
387
|
+
}
|
|
211
388
|
}
|
|
212
389
|
return { stdout: "", stderr, exitCode: stderr ? 1 : 0 };
|
|
213
390
|
}));
|
|
214
|
-
this.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
391
|
+
this.registerCommand(trustedCommand(JOB_WAIT, async (args) => {
|
|
392
|
+
// `-n`: the first of them to end; `%N`: the Nth job this shell holds; a number: a pid
|
|
393
|
+
const any = args.includes("-n");
|
|
394
|
+
const specs = args.filter((arg) => arg !== "-n");
|
|
395
|
+
const held = [...this.jobs.values()];
|
|
396
|
+
const selected = specs.length === 0
|
|
397
|
+
? held
|
|
398
|
+
: specs.map((arg) => (arg.startsWith("%") ? held[Number(arg.slice(1)) - 1] : this.jobs.get(Number(arg)))).filter((job) => !!job);
|
|
399
|
+
if (specs.length > 0 && selected.length !== specs.length) {
|
|
219
400
|
return { stdout: "", stderr: "wait: pid is not a child of this shell\n", exitCode: 127 };
|
|
220
401
|
}
|
|
402
|
+
if (any) {
|
|
403
|
+
if (selected.length === 0)
|
|
404
|
+
return { stdout: "", stderr: "", exitCode: 127 };
|
|
405
|
+
const first = await Promise.race(selected.map((job) => job.promise.then((result) => ({ job, result }))));
|
|
406
|
+
this.jobs.delete(first.job.pid);
|
|
407
|
+
return { stdout: "", stderr: "", exitCode: first.result.exitCode };
|
|
408
|
+
}
|
|
221
409
|
const results = await Promise.all(selected.map((job) => job.promise));
|
|
222
410
|
for (const job of selected)
|
|
223
411
|
this.jobs.delete(job.pid);
|
|
@@ -226,7 +414,7 @@ export class BrowserShell {
|
|
|
226
414
|
}
|
|
227
415
|
registerShellExecutables() {
|
|
228
416
|
for (const name of ["bash", "sh", "zsh"]) {
|
|
229
|
-
this.
|
|
417
|
+
this.registerCommand(trustedCommand(name, async (args, context) => {
|
|
230
418
|
if (args.includes("--version")) {
|
|
231
419
|
return {
|
|
232
420
|
// Native harnesses use this as an executability probe and often
|
|
@@ -241,12 +429,19 @@ export class BrowserShell {
|
|
|
241
429
|
const script = commandIndex >= 0 ? args[commandIndex + 1] : context.stdin;
|
|
242
430
|
if (!script)
|
|
243
431
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
244
|
-
|
|
432
|
+
// A package script that is more than one command (a loop, a list)
|
|
433
|
+
// arrives here as `sh -c`; its words resolve as a typed line's do.
|
|
434
|
+
this.resolveUnknownCommands(script, context.cwd);
|
|
435
|
+
const result = await this.createBash(context.exportedEnv).exec(script, {
|
|
245
436
|
cwd: context.cwd,
|
|
246
|
-
|
|
437
|
+
// A subshell receives only exports; do not reintroduce a default
|
|
438
|
+
// (such as NODE_ENV) that its parent explicitly unexported or unset.
|
|
439
|
+
env: context.exportedEnv ?? {},
|
|
440
|
+
replaceEnv: true,
|
|
247
441
|
...(context.signal ? { signal: context.signal } : {}),
|
|
248
442
|
rawScript: true,
|
|
249
443
|
});
|
|
444
|
+
return { ...result, stderr: teachUnknownCommands(result.stderr) };
|
|
250
445
|
}));
|
|
251
446
|
}
|
|
252
447
|
}
|
|
@@ -276,9 +471,86 @@ function normalizeShellPath(value) {
|
|
|
276
471
|
}
|
|
277
472
|
return `/${parts.join("/")}`;
|
|
278
473
|
}
|
|
474
|
+
// Just Bash's own `wait` builtin returns at once, and a builtin is found
|
|
475
|
+
// before any command the embedding registers; a function is found before a
|
|
476
|
+
// builtin, as in bash, so a script that waits gets `wait` as a function over
|
|
477
|
+
// this shell's job table. It is defined on the script's first line, so every
|
|
478
|
+
// line keeps its number.
|
|
479
|
+
const JOB_WAIT = "__browser_shell_wait";
|
|
480
|
+
function withJobWait(command) {
|
|
481
|
+
return /(^|[^\w-])wait([^\w-]|$)/u.test(command) ? `wait() { ${JOB_WAIT} "$@"; }; ${command}` : command;
|
|
482
|
+
}
|
|
483
|
+
// Just Bash expands `$!` from its interpreter state and has no `&` of its own,
|
|
484
|
+
// so nothing of its API sets it; the one door is that state. A release that
|
|
485
|
+
// moves it fails here by name rather than expanding `$!` to 0.
|
|
486
|
+
function seedLastBackgroundPid(bash, pid) {
|
|
487
|
+
if (pid === 0)
|
|
488
|
+
return;
|
|
489
|
+
const state = bash.state;
|
|
490
|
+
if (!state || typeof state.lastBackgroundPid !== "number") {
|
|
491
|
+
throw new Error("just-bash no longer keeps lastBackgroundPid in its state: $! cannot be set");
|
|
492
|
+
}
|
|
493
|
+
state.lastBackgroundPid = pid;
|
|
494
|
+
}
|
|
495
|
+
// A job is stopped by abort, whose reason names the signal: a WALI program
|
|
496
|
+
// that installed a handler for it runs the handler and ends as it chooses;
|
|
497
|
+
// every other program ends.
|
|
498
|
+
const STOP_SIGNALS = new Set([19, 20, 21, 22]);
|
|
499
|
+
const SIGNALS = { STOP: 19, TSTP: 20, TTIN: 21, TTOU: 22, HUP: 1, INT: 2, QUIT: 3, KILL: 9, USR1: 10, USR2: 12, PIPE: 13, ALRM: 14, TERM: 15 };
|
|
500
|
+
const SIGNAL_REPORTS = { 1: "Hangup", 2: "Interrupt", 3: "Quit", 9: "Killed", 10: "User defined signal 1", 12: "User defined signal 2", 13: "Broken pipe", 14: "Alarm clock", 15: "Terminated" };
|
|
501
|
+
function signalNumber(spec) {
|
|
502
|
+
if (/^\d+$/u.test(spec)) {
|
|
503
|
+
const number = Number(spec);
|
|
504
|
+
return number === 0 || SIGNAL_REPORTS[number] || STOP_SIGNALS.has(number) ? number : undefined;
|
|
505
|
+
}
|
|
506
|
+
return SIGNALS[spec.toUpperCase().replace(/^SIG/u, "")];
|
|
507
|
+
}
|
|
508
|
+
/** `before; background & foreground`: bash backgrounds only the list after the last `;`. */
|
|
509
|
+
/** The function definitions among a list's top-level commands, joined as one text. */
|
|
510
|
+
function functionDefinitions(list) {
|
|
511
|
+
if (!list)
|
|
512
|
+
return "";
|
|
513
|
+
const items = [];
|
|
514
|
+
let quote = "";
|
|
515
|
+
let depth = 0;
|
|
516
|
+
let start = 0;
|
|
517
|
+
const flush = (end) => { const item = list.slice(start, end).trim(); if (item)
|
|
518
|
+
items.push(item); };
|
|
519
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
520
|
+
const character = list[index];
|
|
521
|
+
if (quote) {
|
|
522
|
+
if (character === quote)
|
|
523
|
+
quote = "";
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (character === "\\") {
|
|
527
|
+
index += 1;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
531
|
+
quote = character;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (character === "(" || character === "{")
|
|
535
|
+
depth += 1;
|
|
536
|
+
else if (character === ")" || character === "}")
|
|
537
|
+
depth = Math.max(0, depth - 1);
|
|
538
|
+
else if (depth === 0 && (character === ";" || character === "\n")) {
|
|
539
|
+
flush(index);
|
|
540
|
+
start = index + 1;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
flush(list.length);
|
|
544
|
+
return items.filter((item) => /^(?:function\s+[A-Za-z_][\w-]*\s*(?:\(\s*\))?|[A-Za-z_][\w-]*\s*\(\s*\))\s*[{(]/u.test(item)).join("\n");
|
|
545
|
+
}
|
|
279
546
|
function splitBackgroundSequence(command) {
|
|
547
|
+
// Only the top level is split: a `;` or `&` inside `( )`, `{ }`, `$( )`
|
|
548
|
+
// or backquotes belongs to the group, which runs whole as one job.
|
|
280
549
|
let quote = "";
|
|
550
|
+
let depth = 0;
|
|
551
|
+
let listStart = 0;
|
|
281
552
|
let escaped = false;
|
|
553
|
+
const wordEdge = (at) => at < 0 || at >= command.length || /[\s;&|]/u.test(command[at]);
|
|
282
554
|
for (let index = 0; index < command.length; index += 1) {
|
|
283
555
|
const character = command[index];
|
|
284
556
|
if (escaped) {
|
|
@@ -294,18 +566,41 @@ function splitBackgroundSequence(command) {
|
|
|
294
566
|
quote = "";
|
|
295
567
|
continue;
|
|
296
568
|
}
|
|
297
|
-
if (character === "'" || character === '"') {
|
|
569
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
298
570
|
quote = character;
|
|
299
571
|
continue;
|
|
300
572
|
}
|
|
573
|
+
if (character === "(") {
|
|
574
|
+
depth += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (character === ")") {
|
|
578
|
+
depth = Math.max(0, depth - 1);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (character === "{" && wordEdge(index - 1) && wordEdge(index + 1)) {
|
|
582
|
+
depth += 1;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (character === "}" && wordEdge(index - 1) && wordEdge(index + 1)) {
|
|
586
|
+
depth = Math.max(0, depth - 1);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (depth > 0)
|
|
590
|
+
continue;
|
|
591
|
+
if (character === ";" || character === "\n") {
|
|
592
|
+
listStart = index + 1;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
301
595
|
if (character !== "&" || command[index - 1] === "&" || command[index + 1] === "&"
|
|
302
596
|
|| command[index - 1] === ">" || !/\s/u.test(command[index - 1] ?? "")
|
|
303
597
|
|| !/\s/u.test(command[index + 1] ?? ""))
|
|
304
598
|
continue;
|
|
305
|
-
const
|
|
599
|
+
const before = command.slice(0, listStart).trim().replace(/;$/u, "").trim();
|
|
600
|
+
const background = command.slice(listStart, index).trim();
|
|
306
601
|
const foreground = command.slice(index + 1).trim();
|
|
307
602
|
if (background && foreground)
|
|
308
|
-
return { background, foreground };
|
|
603
|
+
return { before, background, foreground };
|
|
309
604
|
}
|
|
310
605
|
return;
|
|
311
606
|
}
|
|
@@ -368,13 +663,22 @@ function parseWords(command) {
|
|
|
368
663
|
let word = "";
|
|
369
664
|
let quote = "";
|
|
370
665
|
let escaped = false;
|
|
371
|
-
|
|
666
|
+
const characters = [...command.trim()];
|
|
667
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
668
|
+
const character = characters[index];
|
|
372
669
|
if (escaped) {
|
|
373
670
|
word += character;
|
|
374
671
|
escaped = false;
|
|
375
672
|
continue;
|
|
376
673
|
}
|
|
377
674
|
if (character === "\\" && quote !== "'") {
|
|
675
|
+
// Inside double quotes a backslash is literal unless it escapes one of
|
|
676
|
+
// $ ` " \ or a newline, as sh reads it.
|
|
677
|
+
const next = characters[index + 1];
|
|
678
|
+
if (quote === '"' && (next === undefined || !"$`\"\\\n".includes(next))) {
|
|
679
|
+
word += character;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
378
682
|
escaped = true;
|
|
379
683
|
continue;
|
|
380
684
|
}
|
|
@@ -409,4 +713,29 @@ function parseWords(command) {
|
|
|
409
713
|
function trustedCommand(name, execute) {
|
|
410
714
|
return { ...defineCommand(name, execute), trusted: true };
|
|
411
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* What a program receives from the shell. just-bash exports its own
|
|
718
|
+
* parameters beside the user's variables: `?`, `$`, `!` and IFS, whose default
|
|
719
|
+
* value is a newline. No process could receive those as environment
|
|
720
|
+
* variables, and the WALI engine refuses an environment it cannot write to
|
|
721
|
+
* its env file, so a script's spawn of any native program failed with
|
|
722
|
+
* "Invalid WALI environment entry: IFS". Keep what an environment can carry:
|
|
723
|
+
* identifier names, values without NUL or a line break.
|
|
724
|
+
*/
|
|
725
|
+
export function programEnvironment(exported) {
|
|
726
|
+
const environment = {};
|
|
727
|
+
for (const [name, value] of Object.entries(exported ?? {})) {
|
|
728
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) && !/[\u0000\r\n]/u.test(value))
|
|
729
|
+
environment[name] = value;
|
|
730
|
+
}
|
|
731
|
+
return environment;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Just Bash reports a word nothing answers as `bash: <name>: command not
|
|
735
|
+
* found`, which reads as a Linux machine missing a package. The tab's
|
|
736
|
+
* programs are its page's and image's, so the line says that instead.
|
|
737
|
+
*/
|
|
738
|
+
function teachUnknownCommands(stderr) {
|
|
739
|
+
return stderr.replace(/^bash: ([^:\n]+): command not found$\n?/gmu, (_line, name) => `bash: ${commandNotFound(name)}`);
|
|
740
|
+
}
|
|
412
741
|
//# sourceMappingURL=browser-shell.js.map
|