@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
@@ -1,243 +1,89 @@
1
1
  /**
2
2
  * The shell grammar, a practical subset of POSIX sh:
3
3
  *
4
- * script := list (('\n' | ';' | '&') list)*
5
- * list := pipeline (('&&' | '||') pipeline)*
6
- * pipeline := ['!'] command ('|' command)*
7
- * command := (NAME=word)* word* redirect*
8
- * redirect := ('>' | '>>' | '<' | '2>' | '2>>' | '&>') word | '2>&1' | '>&2'
4
+ * script := list (('\n' | ';' | '&') list)*
5
+ * list := pipeline (('&&' | '||') pipeline)*
6
+ * pipeline := ['!'] command ('|' command)*
7
+ * command := simple | compound redirect* | function
8
+ * simple := (NAME=word)* word* redirect*
9
+ * compound := if … then … [elif … then …]* [else …] fi
10
+ * | while|until … do … done | for NAME [in word*] do … done
11
+ * | case word in (pattern [| pattern]*) … ;; … esac
12
+ * | '{' script '}' | '(' script ')'
13
+ * function := NAME '(' ')' compound | 'function' NAME ['(' ')'] compound
14
+ * redirect := ('>' | '>>' | '<' | '2>' | '2>>' | '&>') word | '2>&1' | '>&2' | ('<<' | '<<-') DELIM
9
15
  *
10
- * Words keep their quoting as segments, because expansion (variables, `$(…)`,
11
- * `~`, globs, field splitting) happens at run time and depends on it.
16
+ * Reserved words count only in command position and unquoted, as in sh, so
17
+ * `echo done` prints "done".
12
18
  */
19
+ import type { AndOrList, CaseCommand, Command, CompoundCommand, ForCommand, IfCommand, LoopCommand, Pipeline, Redirect, RedirectOp, Script, Segment, SimpleCommand, Word } from './ast.js';
20
+ import { ShellSyntaxError, tokenize, type Token } from './tokenize.js';
13
21
 
14
- export type Segment =
15
- | { kind: 'text'; value: string; quoted: boolean }
16
- | { kind: 'var'; name: string; quoted: boolean; op?: ':-' | '-' | ':=' | ':+'; arg?: Word }
17
- | { kind: 'cmd'; source: string; quoted: boolean }
18
- | { kind: 'tilde' };
19
- export type Word = Segment[];
20
-
21
- export type RedirectOp = '>' | '>>' | '<' | '2>' | '2>>' | '&>' | '2>&1' | '>&2';
22
- export interface Redirect {
23
- op: RedirectOp;
24
- target?: Word;
25
- }
26
- export interface Command {
27
- assignments: Array<{ name: string; value: Word }>;
28
- words: Word[];
29
- redirects: Redirect[];
30
- }
31
- export interface Pipeline {
32
- negate: boolean;
33
- commands: Command[];
34
- }
35
- export interface AndOrList {
36
- first: Pipeline;
37
- rest: Array<{ op: '&&' | '||'; pipeline: Pipeline }>;
38
- background: boolean;
39
- }
40
- export type Script = AndOrList[];
41
-
42
- type Token = { type: 'word'; word: Word; raw: string } | { type: 'op'; op: string };
43
-
44
- export class ShellSyntaxError extends Error {
45
- /** The input ended inside a quote or after `|`/`&&`: an interactive shell asks for more. */
46
- constructor(message: string, readonly incomplete = false) {
47
- super(message);
48
- }
49
- }
50
-
51
- const OPERATORS = ['2>&1', '>&2', '2>>', '&&', '||', '>>', '2>', '&>', '|', '&', ';', '>', '<', '\n'];
52
- const SPECIAL_VARS = /^[?$#@*!0-9]/;
53
-
54
- /** Find the `)` closing a `$(` at `start` (just after the paren), honouring quotes and nesting. */
55
- function matchParen(input: string, start: number): number {
56
- let depth = 1;
57
- for (let i = start; i < input.length; i++) {
58
- const ch = input[i];
59
- if (ch === '\\') i++;
60
- else if (ch === "'") i = input.indexOf("'", i + 1) < 0 ? input.length : input.indexOf("'", i + 1);
61
- else if (ch === '"') {
62
- for (i++; i < input.length && input[i] !== '"'; i++) if (input[i] === '\\') i++;
63
- } else if (ch === '(') depth++;
64
- else if (ch === ')' && --depth === 0) return i;
65
- }
66
- throw new ShellSyntaxError('unexpected end of input: missing )', true);
67
- }
68
-
69
- /** Parse `$…` at `i` (pointing at `$`); returns the segment and the index after it. */
70
- function readDollar(input: string, i: number, quoted: boolean): [Segment, number] {
71
- const next = input[i + 1];
72
- if (next === '(') {
73
- const end = matchParen(input, i + 2);
74
- return [{ kind: 'cmd', source: input.slice(i + 2, end), quoted }, end + 1];
75
- }
76
- if (next === '{') {
77
- const end = input.indexOf('}', i + 2);
78
- if (end < 0) throw new ShellSyntaxError('unexpected end of input: missing }', true);
79
- const body = input.slice(i + 2, end);
80
- const m = /^([A-Za-z_][\w]*|[?$#@*!0-9])(?:(:-|-|:=|:\+)(.*))?$/s.exec(body);
81
- if (!m) throw new ShellSyntaxError(`bad substitution: \${${body}}`);
82
- const seg: Segment = { kind: 'var', name: m[1]!, quoted };
83
- if (m[2]) {
84
- seg.op = m[2] as ':-';
85
- seg.arg = tokenizeWord(m[3] ?? '', quoted);
86
- }
87
- return [seg, end + 1];
88
- }
89
- if (next && SPECIAL_VARS.test(next)) return [{ kind: 'var', name: next, quoted }, i + 2];
90
- const m = /^[A-Za-z_]\w*/.exec(input.slice(i + 1));
91
- if (!m) return [{ kind: 'text', value: '$', quoted }, i + 1];
92
- return [{ kind: 'var', name: m[0], quoted }, i + 1 + m[0].length];
93
- }
94
-
95
- /** Segments of a word body (used for `${x:-default}` arguments). */
96
- function tokenizeWord(text: string, quoted: boolean): Word {
97
- const tokens = tokenize(text, true);
98
- const word: Word = [];
99
- for (const t of tokens) if (t.type === 'word') word.push(...t.word.map((s) => (s.kind === 'tilde' ? s : { ...s, quoted: quoted || ('quoted' in s && s.quoted) } as Segment)));
100
- return word;
101
- }
102
-
103
- export function tokenize(input: string, wordOnly = false): Token[] {
104
- const tokens: Token[] = [];
105
- let i = 0;
106
- while (i < input.length) {
107
- const ch = input[i]!;
108
- if (ch === ' ' || ch === '\t' || ch === '\r') {
109
- i++;
110
- continue;
111
- }
112
- if (ch === '\\' && input[i + 1] === '\n') {
113
- i += 2;
114
- continue;
115
- }
116
- if (ch === '#') {
117
- while (i < input.length && input[i] !== '\n') i++;
118
- continue;
119
- }
120
- if (!wordOnly) {
121
- const op = OPERATORS.find((o) => input.startsWith(o, i));
122
- if (op) {
123
- tokens.push({ type: 'op', op });
124
- i += op.length;
125
- continue;
126
- }
127
- }
128
- // A word: runs until unquoted whitespace or an operator character.
129
- const word: Word = [];
130
- const start = i;
131
- let text = '';
132
- const flush = () => {
133
- if (text) word.push({ kind: 'text', value: text, quoted: false });
134
- text = '';
135
- };
136
- if (input[i] === '~' && (i + 1 >= input.length || /[\s/;|&<>]/.test(input[i + 1]!))) {
137
- word.push({ kind: 'tilde' });
138
- i++;
139
- }
140
- while (i < input.length) {
141
- const c = input[i]!;
142
- if (/[\s]/.test(c) && !wordOnly) break;
143
- if (!wordOnly && /[;|&<>]/.test(c)) break;
144
- if (c === '\\') {
145
- text += input[i + 1] ?? '';
146
- i += 2;
147
- } else if (c === "'") {
148
- const end = input.indexOf("'", i + 1);
149
- if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated quote', true);
150
- flush();
151
- word.push({ kind: 'text', value: input.slice(i + 1, end), quoted: true });
152
- i = end + 1;
153
- } else if (c === '"') {
154
- flush();
155
- i++;
156
- let inner = '';
157
- const pushInner = () => {
158
- if (inner) word.push({ kind: 'text', value: inner, quoted: true });
159
- inner = '';
160
- };
161
- let closed = false;
162
- while (i < input.length) {
163
- const d = input[i]!;
164
- if (d === '"') {
165
- closed = true;
166
- i++;
167
- break;
168
- }
169
- if (d === '\\' && /["\\$`\n]/.test(input[i + 1] ?? '')) {
170
- inner += input[i + 1];
171
- i += 2;
172
- } else if (d === '$') {
173
- pushInner();
174
- const [seg, next] = readDollar(input, i, true);
175
- word.push(seg);
176
- i = next;
177
- } else if (d === '`') {
178
- pushInner();
179
- const end = input.indexOf('`', i + 1);
180
- if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated `', true);
181
- word.push({ kind: 'cmd', source: input.slice(i + 1, end), quoted: true });
182
- i = end + 1;
183
- } else {
184
- inner += d;
185
- i++;
186
- }
187
- }
188
- if (!closed) throw new ShellSyntaxError('unexpected end of input: unterminated quote', true);
189
- pushInner();
190
- if (!word.length || word[word.length - 1]!.kind !== 'text') word.push({ kind: 'text', value: '', quoted: true });
191
- } else if (c === '$') {
192
- flush();
193
- const [seg, next] = readDollar(input, i, false);
194
- word.push(seg);
195
- i = next;
196
- } else if (c === '`') {
197
- flush();
198
- const end = input.indexOf('`', i + 1);
199
- if (end < 0) throw new ShellSyntaxError('unexpected end of input: unterminated `', true);
200
- word.push({ kind: 'cmd', source: input.slice(i + 1, end), quoted: false });
201
- i = end + 1;
202
- } else {
203
- text += c;
204
- i++;
205
- }
206
- }
207
- flush();
208
- tokens.push({ type: 'word', word, raw: input.slice(start, i) });
209
- }
210
- return tokens;
211
- }
22
+ export type * from './ast.js';
23
+ export { ShellSyntaxError, tokenize } from './tokenize.js';
212
24
 
213
25
  const ASSIGNMENT = /^([A-Za-z_]\w*)=/;
214
- const REDIRECTS = new Set(['>', '>>', '<', '2>', '2>>', '&>', '2>&1', '>&2']);
26
+ const NAME = /^[A-Za-z_][\w-]*$/;
27
+ const REDIRECTS = new Set(['>', '>>', '<', '2>', '2>>', '&>', '2>&1', '>&2', '<<', '<<-']);
28
+ /** Words that end a nested list; never the start of a command. */
29
+ const CLOSERS = new Set(['then', 'elif', 'else', 'fi', 'do', 'done', 'esac', '}']);
215
30
 
216
31
  export function parse(input: string): Script {
217
32
  const tokens = tokenize(input);
218
33
  let pos = 0;
219
- const peek = () => tokens[pos];
34
+ const peek = (): Token | undefined => tokens[pos];
220
35
  const isOp = (op: string) => {
221
36
  const t = tokens[pos];
222
37
  return t?.type === 'op' && t.op === op;
223
38
  };
39
+ const isWord = (raw: string, at = pos) => {
40
+ const t = tokens[at];
41
+ return t?.type === 'word' && t.raw === raw;
42
+ };
43
+ const describe = (t: Token | undefined) => (!t ? 'end of input' : t.type === 'op' ? `\`${t.op.replace('\n', 'newline')}'` : t.type === 'word' ? `\`${t.raw}'` : 'here-document');
44
+ const fail = (t: Token | undefined): never => {
45
+ throw new ShellSyntaxError(t ? `syntax error near unexpected token ${describe(t)}` : 'unexpected end of input', !t);
46
+ };
47
+ const skipNewlines = () => {
48
+ while (isOp('\n')) pos++;
49
+ };
50
+ const skipSeparators = () => {
51
+ while (isOp('\n') || isOp(';')) pos++;
52
+ };
53
+ const expectWord = (raw: string) => {
54
+ skipSeparators();
55
+ if (!isWord(raw)) fail(peek());
56
+ pos++;
57
+ };
224
58
 
225
- const command = (): Command => {
226
- const cmd: Command = { assignments: [], words: [], redirects: [] };
59
+ const redirect = (cmd: { redirects: Redirect[] }): boolean => {
60
+ const t = peek();
61
+ if (t?.type !== 'op' || !REDIRECTS.has(t.op)) return false;
62
+ pos++;
63
+ if (t.op === '2>&1' || t.op === '>&2') {
64
+ cmd.redirects.push({ op: t.op });
65
+ return true;
66
+ }
67
+ const target = peek();
68
+ if (t.op === '<<' || t.op === '<<-') {
69
+ if (target?.type !== 'heredoc' || !target.body) fail(target);
70
+ pos++;
71
+ cmd.redirects.push({ op: t.op, heredoc: { body: (target as { body: Word }).body } });
72
+ return true;
73
+ }
74
+ if (target?.type !== 'word') fail(target);
75
+ pos++;
76
+ cmd.redirects.push({ op: t.op as RedirectOp, target: (target as { word: Word }).word });
77
+ return true;
78
+ };
79
+
80
+ const simple = (): SimpleCommand => {
81
+ const cmd: SimpleCommand = { type: 'simple', assignments: [], words: [], redirects: [] };
227
82
  for (;;) {
228
83
  const t = peek();
229
- if (!t) break;
84
+ if (!t || t.type === 'heredoc') break;
230
85
  if (t.type === 'op') {
231
- if (!REDIRECTS.has(t.op)) break;
232
- pos++;
233
- if (t.op === '2>&1' || t.op === '>&2') {
234
- cmd.redirects.push({ op: t.op });
235
- continue;
236
- }
237
- const target = peek();
238
- if (target?.type !== 'word') throw new ShellSyntaxError(`syntax error near unexpected token \`${target?.type === 'op' ? target.op.replace('\n', 'newline') : 'newline'}'`);
239
- pos++;
240
- cmd.redirects.push({ op: t.op as RedirectOp, target: target.word });
86
+ if (!redirect(cmd)) break;
241
87
  continue;
242
88
  }
243
89
  pos++;
@@ -251,45 +97,196 @@ export function parse(input: string): Script {
251
97
  return cmd;
252
98
  };
253
99
 
100
+ /** A nested list, up to (not including) one of `stops`. */
101
+ const list = (stops: ReadonlySet<string>, stopOps: ReadonlySet<string> = new Set()): Script => {
102
+ const script: Script = [];
103
+ for (;;) {
104
+ skipSeparators();
105
+ const t = peek();
106
+ if (!t) fail(t);
107
+ if (t!.type === 'word' && stops.has(t!.raw)) return script;
108
+ if (t!.type === 'op' && stopOps.has(t!.op)) return script;
109
+ script.push(andOr());
110
+ if (isOp('&')) {
111
+ script[script.length - 1]!.background = true;
112
+ pos++;
113
+ }
114
+ }
115
+ };
116
+
117
+ const ifCommand = (): IfCommand => {
118
+ pos++; // if
119
+ const clauses: IfCommand['clauses'] = [];
120
+ let otherwise: Script | undefined;
121
+ const condition = list(new Set(['then']));
122
+ expectWord('then');
123
+ clauses.push({ condition, body: list(new Set(['elif', 'else', 'fi'])) });
124
+ for (;;) {
125
+ if (isWord('elif')) {
126
+ pos++;
127
+ const next = list(new Set(['then']));
128
+ expectWord('then');
129
+ clauses.push({ condition: next, body: list(new Set(['elif', 'else', 'fi'])) });
130
+ } else if (isWord('else')) {
131
+ pos++;
132
+ otherwise = list(new Set(['fi']));
133
+ } else break;
134
+ }
135
+ expectWord('fi');
136
+ return { type: 'if', clauses, otherwise, redirects: [] };
137
+ };
138
+
139
+ const doBody = (): Script => {
140
+ expectWord('do');
141
+ const body = list(new Set(['done']));
142
+ expectWord('done');
143
+ return body;
144
+ };
145
+
146
+ const loop = (type: 'while' | 'until'): LoopCommand => {
147
+ pos++;
148
+ const condition = list(new Set(['do']));
149
+ return { type, condition, body: doBody(), redirects: [] };
150
+ };
151
+
152
+ const forCommand = (): ForCommand => {
153
+ pos++; // for
154
+ const t = peek();
155
+ if (t?.type !== 'word' || !NAME.test(t.raw)) fail(t);
156
+ const name = (t as { raw: string }).raw;
157
+ pos++;
158
+ skipNewlines();
159
+ let items: Word[] | undefined;
160
+ if (isWord('in')) {
161
+ pos++;
162
+ items = [];
163
+ while (peek()?.type === 'word') items.push((tokens[pos++] as { word: Word }).word);
164
+ }
165
+ return { type: 'for', name, items, body: doBody(), redirects: [] };
166
+ };
167
+
168
+ const caseCommand = (): CaseCommand => {
169
+ pos++; // case
170
+ const subject = peek();
171
+ if (subject?.type !== 'word') fail(subject);
172
+ pos++;
173
+ skipNewlines();
174
+ if (!isWord('in')) fail(peek());
175
+ pos++;
176
+ const arms: CaseCommand['arms'] = [];
177
+ for (;;) {
178
+ skipSeparators();
179
+ if (isWord('esac')) break;
180
+ if (isOp('(')) pos++;
181
+ const patterns: Word[] = [];
182
+ for (;;) {
183
+ const p = peek();
184
+ if (p?.type !== 'word') fail(p);
185
+ patterns.push((p as { word: Word }).word);
186
+ pos++;
187
+ if (isOp('|')) pos++;
188
+ else break;
189
+ }
190
+ if (!isOp(')')) fail(peek());
191
+ pos++;
192
+ const body = list(new Set(['esac']), new Set([';;']));
193
+ arms.push({ patterns, body });
194
+ if (isOp(';;')) pos++;
195
+ }
196
+ pos++; // esac
197
+ return { type: 'case', subject: (subject as { word: Word }).word, arms, redirects: [] };
198
+ };
199
+
200
+ const compound = (): CompoundCommand | undefined => {
201
+ let node: CompoundCommand | undefined;
202
+ if (isWord('if')) node = ifCommand();
203
+ else if (isWord('while')) node = loop('while');
204
+ else if (isWord('until')) node = loop('until');
205
+ else if (isWord('for')) node = forCommand();
206
+ else if (isWord('case')) node = caseCommand();
207
+ else if (isWord('{')) {
208
+ pos++;
209
+ const body = list(new Set(['}']));
210
+ pos++;
211
+ node = { type: 'group', body, redirects: [] };
212
+ } else if (isOp('(')) {
213
+ pos++;
214
+ const body = list(new Set(), new Set([')']));
215
+ pos++;
216
+ node = { type: 'subshell', body, redirects: [] };
217
+ }
218
+ if (node) while (redirect(node));
219
+ return node;
220
+ };
221
+
222
+ const command = (): Command => {
223
+ const t = peek();
224
+ if (t?.type === 'word' && CLOSERS.has(t.raw)) fail(t);
225
+ const block = compound();
226
+ if (block) return block;
227
+ // function NAME [()] body | NAME () body
228
+ const named = isWord('function') ? 1 : 0;
229
+ const nameToken = tokens[pos + named];
230
+ if (nameToken?.type === 'word' && NAME.test(nameToken.raw)) {
231
+ const parens = tokens[pos + named + 1]?.type === 'op' && (tokens[pos + named + 1] as { op: string }).op === '(';
232
+ if (named || parens) {
233
+ pos += named + 1;
234
+ if (parens) {
235
+ pos++;
236
+ if (!isOp(')')) fail(peek());
237
+ pos++;
238
+ }
239
+ skipNewlines();
240
+ const body = compound();
241
+ if (!body) fail(peek());
242
+ return { type: 'function', name: nameToken.raw, body: body!, redirects: [] };
243
+ }
244
+ }
245
+ return simple();
246
+ };
247
+
254
248
  const pipeline = (): Pipeline => {
255
249
  let negate = false;
256
- const t = peek();
257
- if (t?.type === 'word' && t.raw === '!') {
250
+ if (isWord('!')) {
258
251
  negate = true;
259
252
  pos++;
260
253
  }
261
254
  const commands = [command()];
262
255
  while (isOp('|')) {
263
256
  pos++;
264
- while (isOp('\n')) pos++;
257
+ skipNewlines();
265
258
  if (!peek()) throw new ShellSyntaxError('unexpected end of input after |', true);
266
259
  commands.push(command());
267
260
  }
268
- for (const c of commands) if (!c.words.length && !c.assignments.length && !c.redirects.length) throw new ShellSyntaxError('syntax error near unexpected token `|\'');
261
+ for (const c of commands) if (c.type === 'simple' && !c.words.length && !c.assignments.length && !c.redirects.length) fail(peek() ?? { type: 'op', op: '|' });
269
262
  return { negate, commands };
270
263
  };
271
264
 
272
- const script: Script = [];
273
- while (pos < tokens.length) {
274
- if (isOp(';') || isOp('\n')) {
275
- pos++;
276
- continue;
277
- }
265
+ const andOr = (): AndOrList => {
278
266
  const t = peek()!;
279
- if (t.type === 'op' && !REDIRECTS.has(t.op)) throw new ShellSyntaxError(`syntax error near unexpected token \`${t.op}'`);
280
- const list: AndOrList = { first: pipeline(), rest: [], background: false };
267
+ if (t.type === 'op' && !REDIRECTS.has(t.op) && t.op !== '(') fail(t);
268
+ const result: AndOrList = { first: pipeline(), rest: [], background: false };
281
269
  while (isOp('&&') || isOp('||')) {
282
270
  const op = (tokens[pos] as { op: '&&' | '||' }).op;
283
271
  pos++;
284
- while (isOp('\n')) pos++;
272
+ skipNewlines();
285
273
  if (!peek()) throw new ShellSyntaxError(`unexpected end of input after ${op}`, true);
286
- list.rest.push({ op, pipeline: pipeline() });
274
+ result.rest.push({ op, pipeline: pipeline() });
275
+ }
276
+ return result;
277
+ };
278
+
279
+ const script: Script = [];
280
+ while (pos < tokens.length) {
281
+ if (isOp(';') || isOp('\n')) {
282
+ pos++;
283
+ continue;
287
284
  }
285
+ script.push(andOr());
288
286
  if (isOp('&')) {
289
- list.background = true;
287
+ script[script.length - 1]!.background = true;
290
288
  pos++;
291
289
  }
292
- script.push(list);
293
290
  }
294
291
  return script;
295
292
  }
@@ -14,9 +14,10 @@ import { Shell, type JobInput, type ShellCommand, type ShellIO } from './exec.js
14
14
  import { LineEditor } from './lineEditor.js';
15
15
  import { BUILTINS, COREUTILS, SYSTEM_PROGRAMS } from './builtins.js';
16
16
  import { npmCommand, npxCommand } from './npm.js';
17
+ import { pnpmCommand, yarnCommand } from './packageManagers.js';
17
18
  import { glob } from './expand.js';
18
19
 
19
- export const SHELL_COMMANDS: Record<string, ShellCommand> = { npm: npmCommand, npx: npxCommand };
20
+ export const SHELL_COMMANDS: Record<string, ShellCommand> = { npm: npmCommand, npx: npxCommand, pnpm: pnpmCommand, yarn: yarnCommand };
20
21
 
21
22
  function createShell(ctx: ProgramContext, positional: string[] = []): Shell {
22
23
  return new Shell({
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `test` and `[ … ]`: file tests (-e -f -d -s -r -w -x), string tests (-z -n,
3
+ * = == !=), integer comparisons (-eq -ne -lt -le -gt -ge), `!`, `-a`/`-o` and
4
+ * parentheses. Exit 0 for true, 1 for false, 2 for a malformed expression.
5
+ */
6
+ import type { Command, CommandContext } from './context.js';
7
+ import { resolvePath } from './context.js';
8
+
9
+ class TestSyntaxError extends Error {}
10
+
11
+ const FILE_TESTS = new Set(['-e', '-f', '-d', '-s', '-r', '-w', '-x', '-L', '-h']);
12
+ const INT_OPS = new Set(['-eq', '-ne', '-lt', '-le', '-gt', '-ge']);
13
+
14
+ function evaluate(ctx: CommandContext, args: string[]): boolean {
15
+ let pos = 0;
16
+ const peek = () => args[pos];
17
+ const next = () => {
18
+ if (pos >= args.length) throw new TestSyntaxError('argument expected');
19
+ return args[pos++]!;
20
+ };
21
+ const integer = (value: string) => {
22
+ if (!/^\s*-?\d+\s*$/.test(value)) throw new TestSyntaxError(`${value}: integer expression expected`);
23
+ return Number(value);
24
+ };
25
+
26
+ const primary = (): boolean => {
27
+ const token = next();
28
+ if (token === '!') return !primary();
29
+ if (token === '(') {
30
+ const value = or();
31
+ if (next() !== ')') throw new TestSyntaxError("')' expected");
32
+ return value;
33
+ }
34
+ const op = peek();
35
+ if (op !== undefined && (op === '=' || op === '==' || op === '!=' || INT_OPS.has(op)) && args[pos + 1] !== undefined) {
36
+ pos++;
37
+ const right = next();
38
+ if (op === '=' || op === '==') return token === right;
39
+ if (op === '!=') return token !== right;
40
+ const [a, b] = [integer(token), integer(right)];
41
+ return op === '-eq' ? a === b : op === '-ne' ? a !== b : op === '-lt' ? a < b : op === '-le' ? a <= b : op === '-gt' ? a > b : a >= b;
42
+ }
43
+ if (token === '-z' && peek() !== undefined) return next().length === 0;
44
+ if (token === '-n' && peek() !== undefined) return next().length > 0;
45
+ if (FILE_TESTS.has(token) && peek() !== undefined) {
46
+ const path = resolvePath(ctx, next());
47
+ const fs = ctx.fs;
48
+ switch (token) {
49
+ case '-f': return fs.isFile(path);
50
+ case '-d': return fs.isDirectory(path);
51
+ case '-s': {
52
+ const contents = fs.readFile(path);
53
+ return contents !== undefined && contents.length > 0;
54
+ }
55
+ case '-L':
56
+ case '-h': return false;
57
+ default: return fs.exists(path);
58
+ }
59
+ }
60
+ return token.length > 0;
61
+ };
62
+ const and = (): boolean => {
63
+ let value = primary();
64
+ while (peek() === '-a') {
65
+ pos++;
66
+ const right = primary();
67
+ value = value && right;
68
+ }
69
+ return value;
70
+ };
71
+ const or = (): boolean => {
72
+ let value = and();
73
+ while (peek() === '-o') {
74
+ pos++;
75
+ const right = and();
76
+ value = value || right;
77
+ }
78
+ return value;
79
+ };
80
+
81
+ if (!args.length) return false;
82
+ const result = or();
83
+ if (pos < args.length) throw new TestSyntaxError(`${args[pos]}: unexpected argument`);
84
+ return result;
85
+ }
86
+
87
+ function run(ctx: CommandContext, args: string[]): number {
88
+ try {
89
+ return evaluate(ctx, args) ? 0 : 1;
90
+ } catch (error) {
91
+ if (!(error instanceof TestSyntaxError)) throw error;
92
+ ctx.io.stderr(`${ctx.name}: ${error.message}\n`);
93
+ return 2;
94
+ }
95
+ }
96
+
97
+ export const test: Command = async (ctx) => run(ctx, ctx.args);
98
+
99
+ export const bracket: Command = async (ctx) => {
100
+ if (ctx.args[ctx.args.length - 1] !== ']') {
101
+ ctx.io.stderr("[: missing ']'\n");
102
+ return 2;
103
+ }
104
+ return run(ctx, ctx.args.slice(0, -1));
105
+ };