@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.
Files changed (58) hide show
  1. package/README.md +15 -0
  2. package/dist/browser-git-http.d.ts +28 -2
  3. package/dist/browser-git-http.d.ts.map +1 -1
  4. package/dist/browser-git-http.js +5 -1
  5. package/dist/browser-git-http.js.map +1 -1
  6. package/dist/browser-git-remote-https.d.ts +34 -0
  7. package/dist/browser-git-remote-https.d.ts.map +1 -0
  8. package/dist/browser-git-remote-https.js +619 -0
  9. package/dist/browser-git-remote-https.js.map +1 -0
  10. package/dist/browser-interactive-shell.d.ts +11 -0
  11. package/dist/browser-interactive-shell.d.ts.map +1 -0
  12. package/dist/browser-interactive-shell.js +211 -0
  13. package/dist/browser-interactive-shell.js.map +1 -0
  14. package/dist/browser-rm.d.ts +3 -0
  15. package/dist/browser-rm.d.ts.map +1 -0
  16. package/dist/browser-rm.js +61 -0
  17. package/dist/browser-rm.js.map +1 -0
  18. package/dist/browser-shell-filesystem.d.ts +1 -1
  19. package/dist/browser-shell-filesystem.d.ts.map +1 -1
  20. package/dist/browser-shell-filesystem.js +25 -7
  21. package/dist/browser-shell-filesystem.js.map +1 -1
  22. package/dist/browser-shell.d.ts +52 -3
  23. package/dist/browser-shell.d.ts.map +1 -1
  24. package/dist/browser-shell.js +387 -58
  25. package/dist/browser-shell.js.map +1 -1
  26. package/dist/github-device-auth.d.ts +23 -2
  27. package/dist/github-device-auth.d.ts.map +1 -1
  28. package/dist/github-device-auth.js +132 -17
  29. package/dist/github-device-auth.js.map +1 -1
  30. package/dist/github-web-sign-in.d.ts +24 -0
  31. package/dist/github-web-sign-in.d.ts.map +1 -0
  32. package/dist/github-web-sign-in.js +93 -0
  33. package/dist/github-web-sign-in.js.map +1 -0
  34. package/dist/index.d.ts +4 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/install.d.ts +8 -3
  39. package/dist/install.d.ts.map +1 -1
  40. package/dist/install.js +37 -5
  41. package/dist/install.js.map +1 -1
  42. package/dist/shell-buffer-global.d.ts +2 -0
  43. package/dist/shell-buffer-global.d.ts.map +1 -0
  44. package/dist/shell-buffer-global.js +10 -0
  45. package/dist/shell-buffer-global.js.map +1 -0
  46. package/dist/volter-sign-in.d.ts +62 -0
  47. package/dist/volter-sign-in.d.ts.map +1 -0
  48. package/dist/volter-sign-in.js +255 -0
  49. package/dist/volter-sign-in.js.map +1 -0
  50. package/package.json +22 -10
  51. package/dist/browser-git-filesystem.d.ts +0 -18
  52. package/dist/browser-git-filesystem.d.ts.map +0 -1
  53. package/dist/browser-git-filesystem.js +0 -84
  54. package/dist/browser-git-filesystem.js.map +0 -1
  55. package/dist/browser-git.d.ts +0 -38
  56. package/dist/browser-git.d.ts.map +0 -1
  57. package/dist/browser-git.js +0 -514
  58. package/dist/browser-git.js.map +0 -1
@@ -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
- bash;
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
- constructor(filesystem) {
13
- this.virtualFilesystem = filesystem;
14
- this.filesystem = new BrowserShellFileSystem(filesystem);
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.bash = new Bash({
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/supercode",
21
- USER: "supercode",
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
- registerProgram(name, runner) {
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
- this.bash.registerCommand(trustedCommand(name, async (args, context) => {
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
- env: Object.fromEntries(context.env),
103
+ ...(this.fixedPolicy ? { filesystemPolicy: this.fixedPolicy } : {}),
104
+ env: programEnvironment(context.exportedEnv),
39
105
  stdin: context.stdin,
40
106
  ...(context.signal ? { signal: context.signal } : {}),
41
- ...(streaming?.onStdout ? { onStdout: (data) => {
42
- streamedStdout = true;
107
+ onStdout: (data) => {
108
+ streamedStdout = true;
109
+ if (streaming?.onStdout)
43
110
  streaming.onStdout(data);
44
- } } : {}),
45
- ...(streaming?.onStderr ? { onStderr: (data) => {
46
- streamedStderr = true;
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 ? "" : result.stdout,
53
- stderr: streamedStderr ? "" : result.stderr,
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 background = this.startBackground(sequence.background, options);
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, options);
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 job of this.jobs.values())
88
- job.controller.abort(new DOMException("Shell terminated", "AbortError"));
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/supercode", { recursive: true });
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/supercode";
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
- const result = await this.bash.exec(command, {
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?.(result.stderr);
140
- return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, env: result.env };
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, options);
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((result) => {
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
- if (!streamProgram) {
182
- options.onStdout?.(result.stdout);
183
- options.onStderr?.(result.stderr);
184
- }
185
- options.onStderr?.(`[${pid}] Done (${result.exitCode}) ${command}\n`);
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.bash.registerCommand(trustedCommand("jobs", async () => ({
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.bash.registerCommand(trustedCommand("kill", async (args) => {
200
- const targets = args.filter((arg) => !arg.startsWith("-"));
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 PID...\n", exitCode: 2 };
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
- if (!job || job.state !== "running")
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
- else
210
- job.controller.abort(new DOMException("Terminated", "AbortError"));
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.bash.registerCommand(trustedCommand("wait", async (args) => {
215
- const selected = args.length === 0
216
- ? [...this.jobs.values()]
217
- : args.map((arg) => this.jobs.get(Number(arg.replace(/^%/u, "")))).filter((job) => !!job);
218
- if (args.length > 0 && selected.length !== args.length) {
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.bash.registerCommand(trustedCommand(name, async (args, context) => {
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
- return this.bash.exec(script, {
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
- env: Object.fromEntries(context.env),
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 background = command.slice(0, index).trim();
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
- for (const character of command.trim()) {
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