@seanhogg/builderforce-webcontainers-core 2026.10.7 → 2026.10.8

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 (150) hide show
  1. package/dist/archive.d.ts +2 -0
  2. package/dist/archive.d.ts.map +1 -1
  3. package/dist/archive.js +6 -4
  4. package/dist/archive.js.map +1 -1
  5. package/dist/html.d.ts +2 -0
  6. package/dist/html.d.ts.map +1 -1
  7. package/dist/html.js +5 -3
  8. package/dist/html.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/node/http/client.d.ts +3 -1
  14. package/dist/node/http/client.d.ts.map +1 -1
  15. package/dist/node/http/client.js +74 -5
  16. package/dist/node/http/client.js.map +1 -1
  17. package/dist/node/http/index.d.ts.map +1 -1
  18. package/dist/node/http/index.js +2 -1
  19. package/dist/node/http/index.js.map +1 -1
  20. package/dist/node/http/messages.d.ts +87 -0
  21. package/dist/node/http/messages.d.ts.map +1 -0
  22. package/dist/node/http/messages.js +146 -0
  23. package/dist/node/http/messages.js.map +1 -0
  24. package/dist/node/http/server.d.ts +13 -98
  25. package/dist/node/http/server.d.ts.map +1 -1
  26. package/dist/node/http/server.js +34 -202
  27. package/dist/node/http/server.js.map +1 -1
  28. package/dist/node/http/serverResponse.d.ts +42 -0
  29. package/dist/node/http/serverResponse.d.ts.map +1 -0
  30. package/dist/node/http/serverResponse.js +89 -0
  31. package/dist/node/http/serverResponse.js.map +1 -0
  32. package/dist/node/http/upgradeSocket.d.ts +34 -0
  33. package/dist/node/http/upgradeSocket.d.ts.map +1 -0
  34. package/dist/node/http/upgradeSocket.js +73 -0
  35. package/dist/node/http/upgradeSocket.js.map +1 -0
  36. package/dist/node/runtime.js +2 -2
  37. package/dist/node/runtime.js.map +1 -1
  38. package/dist/previewBridge.d.ts +42 -0
  39. package/dist/previewBridge.d.ts.map +1 -0
  40. package/dist/previewBridge.js +192 -0
  41. package/dist/previewBridge.js.map +1 -0
  42. package/dist/shell/ast.d.ts +101 -0
  43. package/dist/shell/ast.d.ts.map +1 -0
  44. package/dist/shell/ast.js +7 -0
  45. package/dist/shell/ast.js.map +1 -0
  46. package/dist/shell/builtins.d.ts +14 -0
  47. package/dist/shell/builtins.d.ts.map +1 -1
  48. package/dist/shell/builtins.js +87 -7
  49. package/dist/shell/builtins.js.map +1 -1
  50. package/dist/shell/compound.d.ts +30 -0
  51. package/dist/shell/compound.d.ts.map +1 -0
  52. package/dist/shell/compound.js +73 -0
  53. package/dist/shell/compound.js.map +1 -0
  54. package/dist/shell/exec.d.ts +27 -6
  55. package/dist/shell/exec.d.ts.map +1 -1
  56. package/dist/shell/exec.js +127 -41
  57. package/dist/shell/exec.js.map +1 -1
  58. package/dist/shell/expand.d.ts +5 -1
  59. package/dist/shell/expand.d.ts.map +1 -1
  60. package/dist/shell/expand.js +7 -5
  61. package/dist/shell/expand.js.map +1 -1
  62. package/dist/shell/lineReader.d.ts +9 -0
  63. package/dist/shell/lineReader.d.ts.map +1 -0
  64. package/dist/shell/lineReader.js +42 -0
  65. package/dist/shell/lineReader.js.map +1 -0
  66. package/dist/shell/packageManagers.d.ts +20 -0
  67. package/dist/shell/packageManagers.d.ts.map +1 -0
  68. package/dist/shell/packageManagers.js +74 -0
  69. package/dist/shell/packageManagers.js.map +1 -0
  70. package/dist/shell/parse.d.ts +16 -66
  71. package/dist/shell/parse.d.ts.map +1 -1
  72. package/dist/shell/parse.js +244 -243
  73. package/dist/shell/parse.js.map +1 -1
  74. package/dist/shell/program.d.ts.map +1 -1
  75. package/dist/shell/program.js +2 -1
  76. package/dist/shell/program.js.map +1 -1
  77. package/dist/shell/testCommand.d.ts +9 -0
  78. package/dist/shell/testCommand.d.ts.map +1 -0
  79. package/dist/shell/testCommand.js +105 -0
  80. package/dist/shell/testCommand.js.map +1 -0
  81. package/dist/shell/tokenize.d.ts +25 -0
  82. package/dist/shell/tokenize.d.ts.map +1 -0
  83. package/dist/shell/tokenize.js +279 -0
  84. package/dist/shell/tokenize.js.map +1 -0
  85. package/dist/system/cookieJar.d.ts +59 -0
  86. package/dist/system/cookieJar.d.ts.map +1 -0
  87. package/dist/system/cookieJar.js +187 -0
  88. package/dist/system/cookieJar.js.map +1 -0
  89. package/dist/system/httpStream.d.ts +40 -0
  90. package/dist/system/httpStream.d.ts.map +1 -0
  91. package/dist/system/httpStream.js +156 -0
  92. package/dist/system/httpStream.js.map +1 -0
  93. package/dist/system/index.d.ts +9 -1
  94. package/dist/system/index.d.ts.map +1 -1
  95. package/dist/system/index.js +4 -0
  96. package/dist/system/index.js.map +1 -1
  97. package/dist/system/kernel.d.ts +10 -2
  98. package/dist/system/kernel.d.ts.map +1 -1
  99. package/dist/system/kernel.js +12 -3
  100. package/dist/system/kernel.js.map +1 -1
  101. package/dist/system/ports.d.ts +9 -4
  102. package/dist/system/ports.d.ts.map +1 -1
  103. package/dist/system/ports.js +21 -2
  104. package/dist/system/ports.js.map +1 -1
  105. package/dist/system/programs.d.ts +1 -1
  106. package/dist/system/programs.d.ts.map +1 -1
  107. package/dist/system/programs.js +2 -0
  108. package/dist/system/programs.js.map +1 -1
  109. package/dist/system/types.d.ts +36 -6
  110. package/dist/system/types.d.ts.map +1 -1
  111. package/dist/system/webSocketClient.d.ts +37 -0
  112. package/dist/system/webSocketClient.d.ts.map +1 -0
  113. package/dist/system/webSocketClient.js +152 -0
  114. package/dist/system/webSocketClient.js.map +1 -0
  115. package/dist/system/webSocketFrames.d.ts +39 -0
  116. package/dist/system/webSocketFrames.d.ts.map +1 -0
  117. package/dist/system/webSocketFrames.js +117 -0
  118. package/dist/system/webSocketFrames.js.map +1 -0
  119. package/package.json +1 -1
  120. package/src/archive.ts +8 -4
  121. package/src/html.ts +5 -3
  122. package/src/index.ts +2 -1
  123. package/src/node/http/client.ts +69 -6
  124. package/src/node/http/index.ts +2 -1
  125. package/src/node/http/messages.ts +150 -0
  126. package/src/node/http/server.ts +37 -202
  127. package/src/node/http/serverResponse.ts +98 -0
  128. package/src/node/http/upgradeSocket.ts +79 -0
  129. package/src/node/runtime.ts +2 -2
  130. package/src/previewBridge.ts +194 -0
  131. package/src/shell/ast.ts +85 -0
  132. package/src/shell/builtins.ts +97 -7
  133. package/src/shell/compound.ts +89 -0
  134. package/src/shell/exec.ts +128 -40
  135. package/src/shell/expand.ts +8 -6
  136. package/src/shell/lineReader.ts +46 -0
  137. package/src/shell/packageManagers.ts +75 -0
  138. package/src/shell/parse.ts +233 -236
  139. package/src/shell/program.ts +2 -1
  140. package/src/shell/testCommand.ts +105 -0
  141. package/src/shell/tokenize.ts +257 -0
  142. package/src/system/cookieJar.ts +187 -0
  143. package/src/system/httpStream.ts +168 -0
  144. package/src/system/index.ts +24 -1
  145. package/src/system/kernel.ts +16 -4
  146. package/src/system/ports.ts +23 -4
  147. package/src/system/programs.ts +3 -1
  148. package/src/system/types.ts +40 -6
  149. package/src/system/webSocketClient.ts +177 -0
  150. package/src/system/webSocketFrames.ts +118 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Running compound commands (`if`, `while`/`until`, `for`, `case`, `{ }`,
3
+ * `( )`) against a narrow {@link ScriptRunner}, so the executor stays a
4
+ * dispatcher. Loop control (`break`, `continue`) and `return` travel as a
5
+ * {@link Flow} the runner holds: lists stop running while one is set, and the
6
+ * construct it targets clears it.
7
+ */
8
+ import type { CompoundCommand, Script, Word } from './ast.js';
9
+ import type { JobInput, ShellIO } from './exec.js';
10
+ import { patternToRegExp } from './expand.js';
11
+
12
+ export type Flow = { kind: 'break' | 'continue'; levels: number } | { kind: 'return'; status: number };
13
+
14
+ export interface ScriptRunner {
15
+ flow?: Flow;
16
+ readonly positional: readonly string[];
17
+ runScript(script: Script, io: ShellIO, input: JobInput, signal: AbortSignal): Promise<number>;
18
+ /** Words with splitting and globbing, as command arguments. */
19
+ expandWords(words: Word[], io: ShellIO, signal: AbortSignal): Promise<string[]>;
20
+ /** One word as a single string: no splitting, no globbing. */
21
+ expandText(word: Word, io: ShellIO, signal: AbortSignal): Promise<string>;
22
+ setVar(name: string, value: string): void;
23
+ /** A copy of this shell for `( … )`: changes inside do not leak out. */
24
+ subshell(): ScriptRunner;
25
+ }
26
+
27
+ /** After one pass of a loop body: whether the loop goes on. Consumes the flow aimed at this loop. */
28
+ function continueLoop(runner: ScriptRunner): boolean {
29
+ const flow = runner.flow;
30
+ if (!flow) return true;
31
+ if (flow.kind === 'return') return false;
32
+ if (flow.levels > 1) {
33
+ runner.flow = { kind: flow.kind, levels: flow.levels - 1 };
34
+ return false;
35
+ }
36
+ runner.flow = undefined;
37
+ return flow.kind === 'continue';
38
+ }
39
+
40
+ export async function runCompound(node: CompoundCommand, runner: ScriptRunner, io: ShellIO, input: JobInput, signal: AbortSignal): Promise<number> {
41
+ switch (node.type) {
42
+ case 'group':
43
+ return runner.runScript(node.body, io, input, signal);
44
+ case 'subshell':
45
+ return runner.subshell().runScript(node.body, io, input, signal);
46
+ case 'if': {
47
+ for (const clause of node.clauses) {
48
+ const condition = await runner.runScript(clause.condition, io, input, signal);
49
+ if (runner.flow) return condition;
50
+ if (condition === 0) return runner.runScript(clause.body, io, input, signal);
51
+ }
52
+ return node.otherwise ? runner.runScript(node.otherwise, io, input, signal) : 0;
53
+ }
54
+ case 'while':
55
+ case 'until': {
56
+ let status = 0;
57
+ while (!signal.aborted) {
58
+ const condition = await runner.runScript(node.condition, io, input, signal);
59
+ if (runner.flow && !continueLoop(runner)) break;
60
+ if ((condition === 0) !== (node.type === 'while')) break;
61
+ status = await runner.runScript(node.body, io, input, signal);
62
+ if (!continueLoop(runner)) break;
63
+ }
64
+ return status;
65
+ }
66
+ case 'for': {
67
+ const items = node.items ? await runner.expandWords(node.items, io, signal) : [...runner.positional];
68
+ let status = 0;
69
+ for (const item of items) {
70
+ if (signal.aborted) break;
71
+ runner.setVar(node.name, item);
72
+ status = await runner.runScript(node.body, io, input, signal);
73
+ if (!continueLoop(runner)) break;
74
+ }
75
+ return status;
76
+ }
77
+ case 'case': {
78
+ const subject = await runner.expandText(node.subject, io, signal);
79
+ for (const arm of node.arms) {
80
+ for (const pattern of arm.patterns) {
81
+ if (patternToRegExp(await runner.expandText(pattern, io, signal), { slash: true }).test(subject)) {
82
+ return runner.runScript(arm.body, io, input, signal);
83
+ }
84
+ }
85
+ }
86
+ return 0;
87
+ }
88
+ }
89
+ }
package/src/shell/exec.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * The executor: runs a parsed script. Lists honour `&&`/`||`/`;`/`&`; pipeline
3
- * stages run concurrently, joined by in-memory pipes; redirects swap a stage's
4
- * stdin/stdout/stderr for files. Dispatch order per command: shell builtins,
3
+ * stages run concurrently, joined by in-memory pipes; redirects (here-documents
4
+ * included) swap a stage's stdin/stdout/stderr. Compound commands run through
5
+ * compound.ts. Dispatch order per simple command: shell functions, builtins,
5
6
  * extra in-process commands (npm, npx), the dev-server handoff, coreutils, and
6
7
  * finally `system.spawn` (node, scripts on PATH, `.bin` entries) — which in the
7
8
  * browser is a separate Web Worker.
8
9
  */
9
10
  import type { System } from '../system/types.js';
10
11
  import { InputQueue } from '../system/terminal.js';
11
- import { parse, ShellSyntaxError, type Command as CommandNode, type Pipeline, type Script } from './parse.js';
12
+ import type { Command as CommandNode, FunctionDefinition, Pipeline, Redirect, Script, SimpleCommand, Word } from './ast.js';
13
+ import { parse, ShellSyntaxError } from './parse.js';
12
14
  import { expandWord, expandWords, type ExpandContext } from './expand.js';
13
- import { BUILTINS, COREUTILS, type ShellState } from './builtins.js';
15
+ import { BUILTINS, COREUTILS, type BackgroundJob, type ShellState } from './builtins.js';
14
16
  import { resolvePath, type CommandContext } from './context.js';
15
17
  import { matchDevServer, runDevServerHandoff } from './devHandoff.js';
18
+ import { runCompound, type Flow, type ScriptRunner } from './compound.js';
16
19
 
17
20
  export interface ShellIO {
18
21
  stdout(text: string): void;
@@ -39,31 +42,44 @@ export interface ShellOptions {
39
42
  /** The terminal size, when attached to one. */
40
43
  terminalSize?: () => { cols: number; rows: number } | undefined;
41
44
  commands?: Record<string, ShellCommand>;
45
+ /** Functions inherited from a parent shell (subshells and `$(…)`). */
46
+ functions?: Map<string, FunctionDefinition>;
42
47
  }
43
48
 
44
49
  let nextShellPid = 500;
45
50
 
46
- export class Shell implements ShellState {
51
+ export class Shell implements ShellState, ScriptRunner {
47
52
  cwd: string;
48
53
  env: Record<string, string>;
49
54
  lastStatus = 0;
50
55
  history: string[] = [];
51
56
  exitRequested?: number;
57
+ flow?: Flow;
58
+ positional: string[];
59
+ readonly functions: Map<string, FunctionDefinition>;
60
+ readonly jobs = new Map<number, BackgroundJob>();
61
+ /** One frame per running function: names declared `local`, with the value to restore. */
62
+ readonly localFrames: Array<Map<string, string | undefined>> = [];
52
63
  readonly pid = nextShellPid++;
53
64
  readonly system: System;
54
- private readonly positional: string[];
65
+ private nextJob = 1;
55
66
  private current?: { io: ShellIO; input: JobInput; signal: AbortSignal };
56
67
 
57
68
  constructor(readonly options: ShellOptions) {
58
69
  this.system = options.system;
59
70
  this.cwd = options.cwd;
60
71
  this.env = { ...options.env, PWD: options.cwd };
61
- this.positional = options.positional ?? [];
72
+ this.positional = [...(options.positional ?? [])];
73
+ this.functions = new Map(options.functions);
62
74
  }
63
75
 
64
- /** A child shell for `npm run` / subshells: same IO, its own cwd and environment. */
76
+ /** A child shell for `npm run` / subshells: same IO, its own cwd, environment and functions. */
65
77
  fork(overrides: { cwd?: string; env?: Record<string, string>; positional?: string[] }): Shell {
66
- return new Shell({ ...this.options, cwd: overrides.cwd ?? this.cwd, env: { ...this.env, ...overrides.env }, positional: overrides.positional });
78
+ return new Shell({ ...this.options, cwd: overrides.cwd ?? this.cwd, env: { ...this.env, ...overrides.env }, positional: overrides.positional ?? this.positional, functions: this.functions });
79
+ }
80
+
81
+ subshell(): Shell {
82
+ return this.fork({});
67
83
  }
68
84
 
69
85
  /** Parse and run `source`. Syntax errors print and return 2. */
@@ -80,23 +96,11 @@ export class Shell implements ShellState {
80
96
  const previous = this.current;
81
97
  this.current = { io, input, signal };
82
98
  try {
83
- for (const list of script) {
84
- if (this.exitRequested !== undefined || signal.aborted) break;
85
- const run = async () => {
86
- let status = await this.runPipeline(list.first, io, input, signal);
87
- for (const { op, pipeline } of list.rest) {
88
- if (this.exitRequested !== undefined || signal.aborted) break;
89
- if ((op === '&&') === (status === 0)) status = await this.runPipeline(pipeline, io, input, signal);
90
- }
91
- return status;
92
- };
93
- if (list.background) {
94
- void run();
95
- this.lastStatus = 0;
96
- } else this.lastStatus = await run();
97
- }
99
+ await this.runScript(script, io, input, signal);
98
100
  } finally {
99
101
  this.current = previous;
102
+ // A `break`/`continue` outside any loop (or `return` outside a function) must not stop later lines.
103
+ if (!this.localFrames.length) this.flow = undefined;
100
104
  }
101
105
  if (signal.aborted && this.lastStatus === 0) this.lastStatus = 130;
102
106
  return this.exitRequested ?? this.lastStatus;
@@ -109,6 +113,40 @@ export class Shell implements ShellState {
109
113
  return this.exec(source, job.io, job.input, job.signal);
110
114
  }
111
115
 
116
+ async runScript(script: Script, io: ShellIO, input: JobInput, signal: AbortSignal): Promise<number> {
117
+ for (const list of script) {
118
+ if (this.exitRequested !== undefined || signal.aborted || this.flow) break;
119
+ const run = async () => {
120
+ let status = await this.runPipeline(list.first, io, input, signal);
121
+ for (const { op, pipeline } of list.rest) {
122
+ if (this.exitRequested !== undefined || signal.aborted || this.flow) break;
123
+ if ((op === '&&') === (status === 0)) status = await this.runPipeline(pipeline, io, input, signal);
124
+ }
125
+ return status;
126
+ };
127
+ if (list.background) {
128
+ const id = this.nextJob++;
129
+ const done = run();
130
+ this.jobs.set(id, { id, done });
131
+ void done.finally(() => this.jobs.delete(id));
132
+ this.lastStatus = 0;
133
+ } else this.lastStatus = await run();
134
+ }
135
+ return this.lastStatus;
136
+ }
137
+
138
+ expandWords(words: Word[], io: ShellIO, signal: AbortSignal): Promise<string[]> {
139
+ return expandWords(words, this.expandContext(io, signal));
140
+ }
141
+
142
+ async expandText(word: Word, io: ShellIO, signal: AbortSignal): Promise<string> {
143
+ return (await expandWord(word, this.expandContext(io, signal), { split: false, glob: false })).join('');
144
+ }
145
+
146
+ setVar(name: string, value: string): void {
147
+ this.env[name] = value;
148
+ }
149
+
112
150
  private expandContext(io: ShellIO, signal: AbortSignal): ExpandContext {
113
151
  return {
114
152
  fs: this.system.fs,
@@ -147,6 +185,21 @@ export class Shell implements ShellState {
147
185
  }
148
186
 
149
187
  private async runCommand(node: CommandNode, io: ShellIO, input: JobInput, signal: AbortSignal, alone: boolean): Promise<number> {
188
+ if (node.type === 'function') {
189
+ this.functions.set(node.name, node);
190
+ return 0;
191
+ }
192
+ if (node.type === 'simple') return this.runSimple(node, io, input, signal, alone);
193
+ const redirected = await this.applyRedirects(node.redirects, io, input, signal);
194
+ if (typeof redirected === 'number') return redirected;
195
+ try {
196
+ return await runCompound(node, this, redirected.io, redirected.input, signal);
197
+ } finally {
198
+ redirected.flush();
199
+ }
200
+ }
201
+
202
+ private async runSimple(node: SimpleCommand, io: ShellIO, input: JobInput, signal: AbortSignal, alone: boolean): Promise<number> {
150
203
  const ectx = this.expandContext(io, signal);
151
204
  const argv = await expandWords(node.words, ectx);
152
205
  const assigned: Record<string, string> = {};
@@ -155,14 +208,27 @@ export class Shell implements ShellState {
155
208
  Object.assign(this.env, assigned);
156
209
  return 0;
157
210
  }
211
+ const redirected = await this.applyRedirects(node.redirects, io, input, signal);
212
+ if (typeof redirected === 'number') return redirected;
213
+ try {
214
+ return await this.dispatch(argv, assigned, redirected.io, redirected.input, signal, alone && redirected.io.isTerminal);
215
+ } finally {
216
+ redirected.flush();
217
+ }
218
+ }
158
219
 
159
- // Redirects.
160
- let stdout = io.stdout;
161
- let stderr = io.stderr;
220
+ /** Apply redirects; returns the new IO, or an exit status when a redirect fails. */
221
+ private async applyRedirects(redirects: Redirect[], io: ShellIO, input: JobInput, signal: AbortSignal): Promise<number | { io: ShellIO; input: JobInput; flush(): void }> {
222
+ let { stdout, stderr, isTerminal } = io;
162
223
  let stdin = input;
163
- let isTerminal = io.isTerminal;
164
224
  const flushes: Array<() => void> = [];
165
- for (const redirect of node.redirects) {
225
+ const fromText = (text: string): JobInput => {
226
+ const queue = new InputQueue();
227
+ queue.push(text);
228
+ queue.push(null);
229
+ return { queue };
230
+ };
231
+ for (const redirect of redirects) {
166
232
  if (redirect.op === '2>&1') {
167
233
  stderr = stdout;
168
234
  continue;
@@ -171,7 +237,12 @@ export class Shell implements ShellState {
171
237
  stdout = stderr;
172
238
  continue;
173
239
  }
174
- const target = (await expandWord(redirect.target!, ectx)).join(' ');
240
+ if (redirect.heredoc) {
241
+ const text = await this.expandText(redirect.heredoc.body, io, signal);
242
+ stdin = fromText(text);
243
+ continue;
244
+ }
245
+ const target = (await expandWord(redirect.target!, this.expandContext(io, signal))).join(' ');
175
246
  const path = resolvePath(this, target);
176
247
  if (redirect.op === '<') {
177
248
  const text = this.system.fs.readText(path);
@@ -179,10 +250,7 @@ export class Shell implements ShellState {
179
250
  io.stderr(`bfsh: ${target}: No such file or directory\n`);
180
251
  return 1;
181
252
  }
182
- const queue = new InputQueue();
183
- queue.push(text);
184
- queue.push(null);
185
- stdin = { queue };
253
+ stdin = fromText(text);
186
254
  continue;
187
255
  }
188
256
  if (this.system.fs.isDirectory(path)) {
@@ -201,16 +269,13 @@ export class Shell implements ShellState {
201
269
  isTerminal = false;
202
270
  }
203
271
  }
204
-
205
- try {
206
- return await this.dispatch(argv, assigned, { stdout, stderr, isTerminal }, stdin, signal, alone && isTerminal);
207
- } finally {
208
- for (const flush of flushes) flush();
209
- }
272
+ return { io: { stdout, stderr, isTerminal }, input: stdin, flush: () => flushes.forEach((flush) => flush()) };
210
273
  }
211
274
 
212
275
  private async dispatch(argv: string[], assigned: Record<string, string>, io: ShellIO, input: JobInput, signal: AbortSignal, attachTerminal: boolean): Promise<number> {
213
276
  const [name, ...args] = argv as [string, ...string[]];
277
+ const fn = this.functions.get(name);
278
+ if (fn) return this.callFunction(fn, args, io, input, signal);
214
279
  const env = { ...this.env, ...assigned };
215
280
  const ctx: CommandContext = {
216
281
  args,
@@ -233,6 +298,29 @@ export class Shell implements ShellState {
233
298
  return this.spawnExternal(name, args, env, io, input, signal, attachTerminal);
234
299
  }
235
300
 
301
+ /** A shell function: its own positional parameters and `local` frame; `return` ends it. */
302
+ private async callFunction(fn: FunctionDefinition, args: string[], io: ShellIO, input: JobInput, signal: AbortSignal): Promise<number> {
303
+ const savedPositional = this.positional;
304
+ this.positional = args;
305
+ const frame = new Map<string, string | undefined>();
306
+ this.localFrames.push(frame);
307
+ try {
308
+ let status = await runCompound(fn.body, this, io, input, signal);
309
+ if (this.flow?.kind === 'return') {
310
+ status = this.flow.status;
311
+ this.flow = undefined;
312
+ }
313
+ return status;
314
+ } finally {
315
+ this.localFrames.pop();
316
+ for (const [name, value] of frame) {
317
+ if (value === undefined) delete this.env[name];
318
+ else this.env[name] = value;
319
+ }
320
+ this.positional = savedPositional;
321
+ }
322
+ }
323
+
236
324
  private async spawnExternal(name: string, args: string[], env: Record<string, string>, io: ShellIO, input: JobInput, signal: AbortSignal, attachTerminal: boolean): Promise<number> {
237
325
  const size = attachTerminal ? this.options.terminalSize?.() : undefined;
238
326
  let child;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { VirtualFileSystem } from '../vfs.js';
8
8
  import { join, normalizePath } from '../paths.js';
9
- import type { Segment, Word } from './parse.js';
9
+ import type { Segment, Word } from './ast.js';
10
10
 
11
11
  export interface ExpandContext {
12
12
  fs: VirtualFileSystem;
@@ -49,12 +49,14 @@ async function paramValue(seg: Extract<Segment, { kind: 'var' }>, ctx: ExpandCon
49
49
 
50
50
  const GLOB_CHARS = /[*?[]/;
51
51
 
52
- function globToRegExp(pattern: string): RegExp {
52
+ /** A glob as a regular expression. `slash: true` lets `*`/`?` cross `/` (a `case` pattern, not a path). */
53
+ export function patternToRegExp(pattern: string, options: { slash?: boolean } = {}): RegExp {
54
+ const any = options.slash ? '.' : '[^/]';
53
55
  let source = '';
54
56
  for (let i = 0; i < pattern.length; i++) {
55
57
  const c = pattern[i]!;
56
- if (c === '*') source += '[^/]*';
57
- else if (c === '?') source += '[^/]';
58
+ if (c === '*') source += `${any}*`;
59
+ else if (c === '?') source += any;
58
60
  else if (c === '[') {
59
61
  const end = pattern.indexOf(']', i + 1);
60
62
  if (end < 0) source += '\\[';
@@ -64,7 +66,7 @@ function globToRegExp(pattern: string): RegExp {
64
66
  }
65
67
  } else source += c.replace(/[.+^${}()|\\]/g, '\\$&');
66
68
  }
67
- return new RegExp(`^${source}$`);
69
+ return new RegExp(`^${source}$`, 's');
68
70
  }
69
71
 
70
72
  export function glob(fs: VirtualFileSystem, cwd: string, pattern: string): string[] {
@@ -80,7 +82,7 @@ export function glob(fs: VirtualFileSystem, cwd: string, pattern: string): strin
80
82
  if (fs.exists(candidate.startsWith('/') ? candidate : join(cwd, candidate))) next.push(candidate);
81
83
  continue;
82
84
  }
83
- const re = globToRegExp(part);
85
+ const re = patternToRegExp(part);
84
86
  for (const name of fs.readdir(normalizePath(dir))) {
85
87
  if (name.startsWith('.') && !part.startsWith('.')) continue;
86
88
  if (re.test(name)) next.push(base ? (base.endsWith('/') ? base + name : `${base}/${name}`) : name);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Reading stdin one line at a time (`read`, `while read line`). A chunk can
3
+ * hold several lines, so what follows the first newline is kept per queue and
4
+ * served to the next read before any new chunk.
5
+ */
6
+ import type { InputQueue } from '../system/terminal.js';
7
+
8
+ const leftovers = new WeakMap<InputQueue, string>();
9
+ const finished = new WeakSet<InputQueue>();
10
+
11
+ /** The next line without its newline, or `null` at end of input. */
12
+ export function readLine(queue: InputQueue, signal?: AbortSignal): Promise<string | null> {
13
+ let buffer = leftovers.get(queue) ?? '';
14
+ const take = (): string | null => {
15
+ const newline = buffer.indexOf('\n');
16
+ if (newline < 0) return null;
17
+ leftovers.set(queue, buffer.slice(newline + 1));
18
+ return buffer.slice(0, newline);
19
+ };
20
+ const ready = take();
21
+ if (ready !== null) return Promise.resolve(ready);
22
+ if (finished.has(queue)) {
23
+ leftovers.delete(queue);
24
+ return Promise.resolve(buffer ? buffer : null);
25
+ }
26
+ return new Promise((resolve) => {
27
+ const settle = (line: string | null) => {
28
+ stop();
29
+ signal?.removeEventListener('abort', onAbort);
30
+ resolve(line);
31
+ };
32
+ const onAbort = () => settle(null);
33
+ signal?.addEventListener('abort', onAbort);
34
+ const stop = queue.subscribe((chunk) => {
35
+ if (chunk === null) {
36
+ finished.add(queue);
37
+ leftovers.delete(queue);
38
+ settle(buffer ? buffer : null);
39
+ return;
40
+ }
41
+ buffer += chunk;
42
+ const line = take();
43
+ if (line !== null) settle(line);
44
+ });
45
+ });
46
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `pnpm` and `yarn`, as front ends to the same installer and script runner
3
+ * `npm` uses: their commands are translated to npm's, so `pnpm add -D x`,
4
+ * `yarn dev` or `pnpm dlx create-vite` behave as a project's README expects.
5
+ * The tree is npm-style (hoisted `node_modules`, `package-lock.json`);
6
+ * `pnpm-lock.yaml` and `yarn.lock` are not read.
7
+ */
8
+ import { join } from '../paths.js';
9
+ import { findPackageScope } from '../node/resolve.js';
10
+ import { findExecutable } from '../system/commands.js';
11
+ import type { CommandContext } from './context.js';
12
+ import type { JobInput, Shell } from './exec.js';
13
+ import { npmCommand, npxCommand } from './npm.js';
14
+
15
+ const VERSIONS = { pnpm: '9.12.0', yarn: '1.22.22' } as const;
16
+
17
+ type Manager = keyof typeof VERSIONS;
18
+
19
+ const INSTALL = new Set(['install', 'i']);
20
+ const REMOVE = new Set(['remove', 'rm', 'uninstall', 'un']);
21
+ const NPM_PASSTHROUGH = new Set(['test', 't', 'start', 'init', 'ls', 'list', 'run', 'run-script']);
22
+
23
+ function hasScript(ctx: CommandContext, name: string): boolean {
24
+ const dir = findPackageScope(ctx.fs, ctx.cwd)?.dir ?? ctx.cwd;
25
+ try {
26
+ const pkg = JSON.parse(ctx.fs.readText(join(dir, 'package.json')) ?? '{}') as { scripts?: Record<string, string> };
27
+ return !!pkg.scripts?.[name];
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** Map `--dev` / `-D` to npm's flag; drop flags npm has no use for here. */
34
+ function addFlags(args: string[]): string[] {
35
+ return args.flatMap((a) => (a === '--dev' || a === '-D' || a === '--save-dev' ? ['--save-dev'] : a === '--frozen-lockfile' || a === '--immutable' || a === '--prefer-offline' ? [] : [a]));
36
+ }
37
+
38
+ /** The npm (or npx) command line a pnpm/yarn command stands for, or a message when there is none. */
39
+ export function translate(manager: Manager, args: string[], ctx: CommandContext): { tool: 'npm' | 'npx'; args: string[] } | { version: string } | { error: string } {
40
+ const [sub, ...rest] = args;
41
+ if (sub === undefined) return { tool: 'npm', args: ['install'] };
42
+ if (sub === '-v' || sub === '--version') return { version: VERSIONS[manager] };
43
+ if (INSTALL.has(sub)) {
44
+ // `yarn install x` is an error in yarn; treat any operands as `add` for both.
45
+ const flags = addFlags(rest);
46
+ return { tool: 'npm', args: ['install', ...flags] };
47
+ }
48
+ if (sub === 'add') return { tool: 'npm', args: ['install', ...addFlags(rest)] };
49
+ if (REMOVE.has(sub)) return { tool: 'npm', args: ['uninstall', ...rest] };
50
+ if (sub === 'dlx' || sub === 'exec' || (manager === 'pnpm' && sub === 'x')) return { tool: 'npx', args: rest };
51
+ if (NPM_PASSTHROUGH.has(sub)) return { tool: 'npm', args: [sub, ...rest] };
52
+ // `pnpm dev` / `yarn build`: a script of that name, else an installed bin.
53
+ if (hasScript(ctx, sub)) return { tool: 'npm', args: ['run', sub, ...(rest.length ? ['--', ...rest] : [])] };
54
+ if (findExecutable(ctx.fs, sub, ctx.cwd, { PATH: '' })) return { tool: 'npx', args: [sub, ...rest] };
55
+ return { error: `${manager}: no script or command named "${sub}"` };
56
+ }
57
+
58
+ function frontEnd(manager: Manager) {
59
+ return async (ctx: CommandContext, shell: Shell, input: JobInput): Promise<number> => {
60
+ const plan = translate(manager, ctx.args, ctx);
61
+ if ('version' in plan) {
62
+ ctx.io.stdout(`${plan.version}\n`);
63
+ return 0;
64
+ }
65
+ if ('error' in plan) {
66
+ ctx.io.stderr(`${plan.error}\n`);
67
+ return 1;
68
+ }
69
+ const next = { ...ctx, args: plan.args, name: plan.tool };
70
+ return plan.tool === 'npm' ? npmCommand(next, shell, input) : npxCommand(next, shell, input);
71
+ };
72
+ }
73
+
74
+ export const pnpmCommand = frontEnd('pnpm');
75
+ export const yarnCommand = frontEnd('yarn');