buninu-linux 0.7.5 → 0.8.55

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 (47) hide show
  1. package/Dockerfile +5 -2
  2. package/NOTICE.md +110 -49
  3. package/README.md +439 -144
  4. package/fetch-npm.sh +44 -0
  5. package/fetch-plus-build.sh +1 -0
  6. package/index.js +15 -13
  7. package/initramfs/bin/buninu-browser +24 -0
  8. package/initramfs/bin/jspinyin +12 -0
  9. package/initramfs/bin/jspulse +2 -0
  10. package/initramfs/bin/tar +40 -6
  11. package/initramfs/bin/top +1 -7
  12. package/initramfs/buninu/CHANGELOG.md +4 -0
  13. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +28 -0
  14. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  15. package/initramfs/buninu/apps/bunmsh/src/main.js +7 -5
  16. package/initramfs/buninu/apps/bunmsh/src/shell.js +584 -75
  17. package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +64 -0
  18. package/initramfs/buninu/package.json +1 -1
  19. package/initramfs/init.js +5 -0
  20. package/initramfs/lib/browser-resources.js +25 -0
  21. package/initramfs/lib/tar-exclude.js +20 -0
  22. package/initramfs/lib/tar-strip.js +94 -0
  23. package/initramfs/lib/top-memory.js +12 -0
  24. package/initramfs/usr/share/doc/buninu-linux/tar.md +24 -1
  25. package/initramfs/usr/share/doc/buninu-linux/top.md +18 -0
  26. package/package.json +1 -1
  27. package/scripts/arch.js +5 -5
  28. package/scripts/arch.sh +5 -5
  29. package/scripts/fetch-npm.js +51 -0
  30. package/scripts/fetch.js +1 -0
  31. package/scripts/js-tools.js +3 -0
  32. package/scripts/npm-packages.md +18 -0
  33. package/scripts/pack-initramfs.sh +1 -0
  34. package/scripts/pack.js +2 -0
  35. package/scripts/stage-npm.js +57 -0
  36. package/scripts/stage-npm.sh +62 -0
  37. package/test/fetch-npm.test.js +92 -0
  38. package/test/host/Dockerfile +6 -4
  39. package/test/host/boot-test.exp +2 -2
  40. package/test/host/host-test.sh +2 -2
  41. package/test/host/in-container.sh +4 -4
  42. package/test/js-tools-selection.test.js +19 -0
  43. package/test/npm-launchers.test.js +59 -0
  44. package/test/stage-npm.test.js +130 -0
  45. package/test/tar-strip.test.js +218 -0
  46. package/test/top-memory.test.js +23 -0
  47. package/initramfs/buninu/apps/bunmsh/test/shell.test.js +0 -2092
@@ -284,23 +284,22 @@ export function tokenize(source, options = {}) {
284
284
  continue;
285
285
  }
286
286
  const two = source.slice(i, i + 2);
287
- const fdDup = source.slice(i, i + 4);
287
+ const fdDup = /^(\d+>&\d+)/.exec(source.slice(i))?.[1] ?? "";
288
288
  const operatorAhead =
289
289
  ["|", ";", "<", ">"].includes(ch) ||
290
290
  two === "&&" ||
291
291
  two === "||" ||
292
292
  two === "2>" ||
293
- fdDup === "1>&2" ||
294
- fdDup === "2>&1";
293
+ Boolean(fdDup);
295
294
  if (current && operatorAhead) {
296
295
  finishWord();
297
296
  continue;
298
297
  }
299
298
  // A descriptor duplication begins with the shorter `2>` redirect token,
300
299
  // so it must win before ordinary file redirects are considered.
301
- if (!current && (fdDup === "1>&2" || fdDup === "2>&1")) {
300
+ if (!current && fdDup) {
302
301
  tokens.push({ type: "op", value: fdDup, offset: i });
303
- i += 4;
302
+ i += fdDup.length;
304
303
  atWordBoundary = true;
305
304
  continue;
306
305
  }
@@ -363,7 +362,7 @@ export function tokenize(source, options = {}) {
363
362
  }
364
363
  if (source[i] === "\\" && i + 1 < source.length) {
365
364
  const next = source[i + 1];
366
- if (next === "$" || next === '"' || next === "\\") {
365
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
367
366
  text += `\u0000${next}`;
368
367
  i += 2;
369
368
  continue;
@@ -418,8 +417,7 @@ export function tokenize(source, options = {}) {
418
417
  source.slice(i, i + 2) === "&&" ||
419
418
  source.slice(i, i + 2) === "||" ||
420
419
  source.slice(i, i + 2) === "2>" ||
421
- source.slice(i, i + 4) === "1>&2" ||
422
- source.slice(i, i + 4) === "2>&1";
420
+ /^\d+>&\d+/.test(source.slice(i));
423
421
  if (isSpace(c) || c === "\n" || c === "'" || c === '"' || c === "\\" || maybeOp)
424
422
  break;
425
423
  text += c;
@@ -466,7 +464,7 @@ export function parse(source) {
466
464
  const token = tokens[i];
467
465
  if (token.type === "op" && ["|", ";", "&&", "||"].includes(token.value))
468
466
  break;
469
- if (token.type === "op" && ["1>&2", "2>&1"].includes(token.value)) {
467
+ if (token.type === "op" && /^\d+>&\d+$/.test(token.value)) {
470
468
  command.redirects.push({ op: token.value, target: null });
471
469
  i++;
472
470
  continue;
@@ -618,6 +616,66 @@ function globPatternRegex(pattern, anchoredStart, anchoredEnd, greedy) {
618
616
  return new RegExp(`${anchoredStart ? "^" : ""}${source}${anchoredEnd ? "$" : ""}`);
619
617
  }
620
618
 
619
+ async function casePatternRegex(pattern, state) {
620
+ let marked = "";
621
+ const literal = (value) => {
622
+ for (const ch of value) marked += `\u0000${ch}`;
623
+ };
624
+ for (let i = 0; i < pattern.length;) {
625
+ if (pattern[i] === "\\") {
626
+ if (i + 1 < pattern.length) literal(pattern[i + 1]);
627
+ i += 2;
628
+ continue;
629
+ }
630
+ if (pattern[i] === "'") {
631
+ const end = pattern.indexOf("'", i + 1);
632
+ if (end < 0) throw new ShellSyntaxError("unterminated single quote in case pattern");
633
+ literal(pattern.slice(i + 1, end));
634
+ i = end + 1;
635
+ continue;
636
+ }
637
+ if (pattern[i] === '"') {
638
+ let end = i + 1;
639
+ while (end < pattern.length && pattern[end] !== '"') {
640
+ if (pattern[end] === "\\") end++;
641
+ end++;
642
+ }
643
+ if (end >= pattern.length) throw new ShellSyntaxError("unterminated double quote in case pattern");
644
+ literal(stripExpansionMarkers(await expandText(pattern.slice(i + 1, end), state)));
645
+ i = end + 1;
646
+ continue;
647
+ }
648
+ let end = i;
649
+ while (end < pattern.length && !["\\", "'", '"'].includes(pattern[end])) end++;
650
+ marked += stripExpansionMarkers(await expandText(pattern.slice(i, end), state));
651
+ i = end;
652
+ }
653
+ let source = "";
654
+ for (let i = 0; i < marked.length; i++) {
655
+ if (marked[i] === "\u0000") {
656
+ source += (marked[++i] ?? "").replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
657
+ } else if (marked[i] === "*") source += ".*";
658
+ else if (marked[i] === "?") source += ".";
659
+ else if (marked[i] === "[") {
660
+ let end = i + 1;
661
+ if (marked[end] === "!" || marked[end] === "^") end++;
662
+ if (marked[end] === "]") end++;
663
+ while (end < marked.length && marked[end] !== "]") end++;
664
+ if (end >= marked.length) source += "\\[";
665
+ else {
666
+ let body = marked.slice(i + 1, end);
667
+ const negate = body[0] === "!" || body[0] === "^";
668
+ if (negate) body = body.slice(1);
669
+ body = body.replaceAll("\\", "\\\\");
670
+ source += `[${negate ? "^" : ""}${body}]`;
671
+ i = end;
672
+ }
673
+ }
674
+ else source += marked[i].replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
675
+ }
676
+ return new RegExp(`^${source}$`);
677
+ }
678
+
621
679
  async function parameterExpansion(body, state) {
622
680
  if (/^[!%][A-Za-z_][A-Za-z0-9_]*$/.test(body)) {
623
681
  const value = parameterValue(body.slice(1), state);
@@ -2925,6 +2983,9 @@ function compoundLogicalSource(source) {
2925
2983
  breaks.add(token.offset);
2926
2984
  if (["elif", "else", "fi", "done", "esac"].includes(word))
2927
2985
  breaks.add(token.offset);
2986
+ if (["fi", "done", "esac"].includes(word) && tokens[index + 1]?.value === ";" &&
2987
+ tokens[index + 2] && tokens[index + 2].value !== ";")
2988
+ breaks.add(tokens[index + 2].offset);
2928
2989
  if (["then", "do", "else"].includes(word) && tokens[index + 1])
2929
2990
  breaks.add(tokens[index + 1].offset);
2930
2991
  commandStart = ["if", "while", "until", "elif", "then", "do", "else"].includes(word);
@@ -2936,14 +2997,12 @@ function compoundLogicalSource(source) {
2936
2997
  return logical;
2937
2998
  }
2938
2999
 
2939
- // A leaf command line inside a compound body (if/while/for/case) is scanned
2940
- // one physical line at a time, but a here-document's body lives on the lines
2941
- // that follow it. Grow the line by re-tokenizing with more lines appended
2942
- // until the heredoc(s) it opens are fully resolved, so the folded lines never
2943
- // get misread as separate commands or as clauses of the surrounding compound.
2944
- function growHeredocLine(lines, index) {
3000
+ // A leaf command inside a compound body may continue across physical lines
3001
+ // inside quotes, substitutions, or a here-document. Keep those lines together
3002
+ // until mksh-style lexical tokenization says the command is complete, so
3003
+ // keywords inside a multiline word are never mistaken for compound syntax.
3004
+ function growLogicalCommand(lines, index) {
2945
3005
  const line = lines[index];
2946
- if (!line.includes("<<")) return { source: line, next: index + 1 };
2947
3006
  let end = index;
2948
3007
  let candidate = line;
2949
3008
  while (true) {
@@ -2952,7 +3011,7 @@ function growHeredocLine(lines, index) {
2952
3011
  return { source: candidate, next: end + 1 };
2953
3012
  } catch (error) {
2954
3013
  if (!(error instanceof ShellSyntaxError) ||
2955
- !error.message.startsWith("unterminated here-document"))
3014
+ !/^(?:unterminated (?:here-document|single quote|double quote|command substitution|substitution))/.test(error.message))
2956
3015
  throw error;
2957
3016
  end++;
2958
3017
  if (end >= lines.length) throw error;
@@ -2961,7 +3020,199 @@ function growHeredocLine(lines, index) {
2961
3020
  }
2962
3021
  }
2963
3022
 
2964
- function parseCompoundScript(source) {
3023
+ function makeCompoundPostfix(compound, suffix, source, nextLine) {
3024
+ const match = /^\s*(\|\||&&|\||;)\s*(.*)$/.exec(suffix ?? "");
3025
+ if (!match) return { node: compound, nextLine };
3026
+ let command = match[2].trim();
3027
+ while (!command && nextLine < source.length) {
3028
+ command = source[nextLine++].trim();
3029
+ if (!command || command.startsWith("#")) command = "";
3030
+ }
3031
+ if (!command)
3032
+ throw new ShellSyntaxError(`compound command requires a command after ${match[1]}`);
3033
+ return {
3034
+ node: {
3035
+ type: "compoundPostfix",
3036
+ compound,
3037
+ operator: match[1],
3038
+ command,
3039
+ },
3040
+ nextLine,
3041
+ };
3042
+ }
3043
+
3044
+ function findShellKeyword(source, keyword) {
3045
+ if (source.trimStart().startsWith("#")) return -1;
3046
+ let quote = null;
3047
+ for (let i = 0; i < source.length;) {
3048
+ const ch = source[i];
3049
+ if (quote === "'") {
3050
+ if (ch === "'") quote = null;
3051
+ i++;
3052
+ continue;
3053
+ }
3054
+ if (quote === '"') {
3055
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i += 2; continue; }
3056
+ if (ch === '"') quote = null;
3057
+ i++;
3058
+ continue;
3059
+ }
3060
+ if (ch === "\\") { i += 2; continue; }
3061
+ if (ch === "'" || ch === '"') { quote = ch; i++; continue; }
3062
+ if (ch === "`" || (ch === "$" && source[i + 1] === "(")) {
3063
+ try { i = substitutionEnd(source, i); }
3064
+ catch { return -1; } // This physical line may continue the substitution.
3065
+ continue;
3066
+ }
3067
+ if (ch === "$" && source[i + 1] === "{") {
3068
+ try { i = parameterEnd(source, i); }
3069
+ catch { return -1; }
3070
+ continue;
3071
+ }
3072
+ if (/[A-Za-z_]/.test(ch)) {
3073
+ const start = i++;
3074
+ while (/[A-Za-z0-9_]/.test(source[i] ?? "")) i++;
3075
+ if (source.slice(start, i) === keyword) {
3076
+ let previous = start - 1;
3077
+ while (previous >= 0 && /\s/.test(source[previous])) previous--;
3078
+ if (keyword !== "esac" || previous < 0 || source[previous] === ";" || source[previous] === "\n")
3079
+ return start;
3080
+ }
3081
+ continue;
3082
+ }
3083
+ i++;
3084
+ }
3085
+ return -1;
3086
+ }
3087
+
3088
+ function splitCasePatterns(source) {
3089
+ const patterns = [];
3090
+ let start = 0;
3091
+ let quote = null;
3092
+ for (let i = 0; i < source.length; i++) {
3093
+ const ch = source[i];
3094
+ if (ch === "\\") { i++; continue; }
3095
+ if (quote) {
3096
+ if (ch === quote) quote = null;
3097
+ continue;
3098
+ }
3099
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3100
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3101
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3102
+ continue;
3103
+ }
3104
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3105
+ if (ch === "|") { patterns.push(source.slice(start, i).trim()); start = i + 1; }
3106
+ }
3107
+ patterns.push(source.slice(start).trim());
3108
+ return patterns;
3109
+ }
3110
+
3111
+ // Scan case clauses in the same lexical spirit as mksh's CASEPAT/KEYWORD
3112
+ // modes. In particular, clause boundaries are shell tokens, not line
3113
+ // boundaries: Autoconf commonly writes an entire clause on one line.
3114
+ function parseCaseArms(source) {
3115
+ const arms = [];
3116
+ let i = 0;
3117
+ const skipSpace = () => {
3118
+ while (/\s/.test(source[i] ?? "")) i++;
3119
+ };
3120
+ while (i < source.length) {
3121
+ skipSpace();
3122
+ if (i >= source.length) break;
3123
+ if (source[i] === "#") {
3124
+ while (i < source.length && source[i] !== "\n") i++;
3125
+ continue;
3126
+ }
3127
+ if (source[i] === "(") { i++; skipSpace(); }
3128
+ const patternStart = i;
3129
+ let quote = null;
3130
+ let extensionDepth = 0;
3131
+ for (; i < source.length; i++) {
3132
+ const ch = source[i];
3133
+ if (ch === "\\") { i++; continue; }
3134
+ if (quote) {
3135
+ if (ch === quote) quote = null;
3136
+ continue;
3137
+ }
3138
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3139
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3140
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3141
+ continue;
3142
+ }
3143
+ if (ch === "`") { i = substitutionEnd(source, i) - 1; continue; }
3144
+ if (ch === "(" && /[?*+@!]/.test(source[i - 1] ?? "")) { extensionDepth++; continue; }
3145
+ if (ch === ")" && extensionDepth) { extensionDepth--; continue; }
3146
+ if (ch === ")") break;
3147
+ }
3148
+ if (i >= source.length)
3149
+ throw new ShellSyntaxError(`case pattern requires ): ${source.slice(patternStart, Math.min(source.length, patternStart + 120))}`);
3150
+ const patterns = splitCasePatterns(source.slice(patternStart, i));
3151
+ if (!patterns.length || patterns.some((pattern) => !pattern))
3152
+ throw new ShellSyntaxError("case requires a pattern");
3153
+ i++;
3154
+
3155
+ const bodyStart = i;
3156
+ quote = null;
3157
+ let nestedCases = 0;
3158
+ let word = "";
3159
+ let terminator = null;
3160
+ const finishWord = () => {
3161
+ if (word === "case") nestedCases++;
3162
+ else if (word === "esac" && nestedCases) nestedCases--;
3163
+ word = "";
3164
+ };
3165
+ for (; i < source.length; i++) {
3166
+ const ch = source[i];
3167
+ if (ch === "#" && !word && (i === bodyStart || /[\s;|&]/.test(source[i - 1] ?? ""))) {
3168
+ while (i < source.length && source[i] !== "\n") i++;
3169
+ continue;
3170
+ }
3171
+ if (ch === "\\") { word = ""; i++; continue; }
3172
+ if (quote) {
3173
+ if (ch === quote) quote = null;
3174
+ continue;
3175
+ }
3176
+ if (ch === "'" || ch === '"') { finishWord(); quote = ch; continue; }
3177
+ if (ch === "$" && (source[i + 1] === "(" || source[i + 1] === "{")) {
3178
+ finishWord();
3179
+ i = (source[i + 1] === "(" ? substitutionEnd(source, i) : parameterEnd(source, i)) - 1;
3180
+ continue;
3181
+ }
3182
+ if (ch === "`") {
3183
+ finishWord();
3184
+ try { i = substitutionEnd(source, i) - 1; }
3185
+ catch (error) { error.message += ` near ${JSON.stringify(source.slice(Math.max(0, i - 100), i + 180))}`; throw error; }
3186
+ continue;
3187
+ }
3188
+ if (/[A-Za-z_]/.test(ch)) { word += ch; continue; }
3189
+ finishWord();
3190
+ if (!nestedCases && ch === ";" && [";", "&", "|"].includes(source[i + 1])) {
3191
+ terminator = source.slice(i, i + 2);
3192
+ break;
3193
+ }
3194
+ }
3195
+ // POSIX allows the final clause to omit `;;` immediately before esac.
3196
+ if (!terminator) {
3197
+ if (source.slice(bodyStart, i).trim() || arms.length) terminator = ";;";
3198
+ else throw new ShellSyntaxError("unterminated case clause");
3199
+ }
3200
+ const bodySource = source.slice(bodyStart, i).trim();
3201
+ let body;
3202
+ try {
3203
+ body = parseCompoundScript(bodySource);
3204
+ } catch (error) {
3205
+ if (error instanceof ShellSyntaxError)
3206
+ error.message += ` in case arm ${patterns.join("|")}`;
3207
+ throw error;
3208
+ }
3209
+ arms.push({ patterns, body, terminator });
3210
+ if (i < source.length) i += 2;
3211
+ }
3212
+ return arms;
3213
+ }
3214
+
3215
+ export function parseCompoundScript(source) {
2965
3216
  const lines = compoundLogicalSource(source.replace(/\\\r?\n/g, "")).split(/\r?\n/);
2966
3217
  const parseSequence = (start, stops = []) => {
2967
3218
  const nodes = [];
@@ -2989,102 +3240,163 @@ function parseCompoundScript(source) {
2989
3240
  }
2990
3241
 
2991
3242
  if (/^if(?:\s|$)/.test(trimmed)) {
3243
+ const compoundStart = i;
2992
3244
  const branches = [];
2993
3245
  let header = trimmed;
2994
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
3246
+ while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
2995
3247
  header += ` ${lines[i].trim()}`;
2996
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("if requires then");
2997
- let condition = header.replace(/^if\s*/, "").replace(/;\s*then\s*$/, "");
3248
+ let then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3249
+ if (!then) throw new ShellSyntaxError("if requires then");
3250
+ let condition = header.slice(0, then.index).replace(/^if\s*/, "").trim();
3251
+ let thenCommand = then[1]?.trim() ?? "";
2998
3252
  i++;
2999
3253
  while (true) {
3000
- const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\s*;?$/]);
3001
- branches.push({ condition, body: body.nodes });
3254
+ const body = parseSequence(i, [/^elif(?:\s|$)/, /^else\s*;?$/, /^fi\b/]);
3255
+ branches.push({ condition, body: body.nodes, thenCommand });
3002
3256
  i = body.index;
3003
3257
  const stop = lines[i]?.trim() ?? "";
3004
3258
  if (/^elif(?:\s|$)/.test(stop)) {
3005
3259
  header = stop;
3006
- while (!/;\s*then\s*$/.test(header) && ++i < lines.length)
3260
+ while (!/(?:;\s*|\s+)then(?:\s+.*)?$/.test(header) && ++i < lines.length)
3007
3261
  header += ` ${lines[i].trim()}`;
3008
- if (!/;\s*then\s*$/.test(header)) throw new ShellSyntaxError("elif requires then");
3009
- condition = header.replace(/^elif\s*/, "").replace(/;\s*then\s*$/, "");
3262
+ then = /(?:;\s*|\s+)then(?:\s+(.*))?$/.exec(header);
3263
+ if (!then) throw new ShellSyntaxError("elif requires then");
3264
+ condition = header.slice(0, then.index).replace(/^elif\s*/, "").trim();
3265
+ thenCommand = then[1]?.trim() ?? "";
3010
3266
  i++;
3011
3267
  continue;
3012
3268
  }
3013
3269
  let otherwise = [];
3014
3270
  if (/^else\s*;?$/.test(stop)) {
3015
- const body = parseSequence(i + 1, [/^fi\s*;?$/]);
3271
+ const body = parseSequence(i + 1, [/^fi\b/]);
3016
3272
  otherwise = body.nodes;
3017
3273
  i = body.index;
3018
3274
  }
3019
- if (!/^fi\s*;?$/.test(lines[i]?.trim() ?? ""))
3275
+ if (!/^fi\b/.test(lines[i]?.trim() ?? ""))
3020
3276
  throw new ShellSyntaxError("unterminated if");
3021
- nodes.push({ type: "if", branches, otherwise });
3022
- i++;
3277
+ const compound = { type: "if", branches, otherwise };
3278
+ const close = /^fi\b(.*)$/.exec(lines[i].trim());
3279
+ const result = makeCompoundPostfix(
3280
+ { ...compound, source: [...lines.slice(compoundStart, i), "fi"].join("\n") },
3281
+ close?.[1] ?? "",
3282
+ lines,
3283
+ i + 1,
3284
+ );
3285
+ nodes.push(result.node);
3286
+ i = result.nextLine;
3023
3287
  break;
3024
3288
  }
3025
3289
  continue;
3026
3290
  }
3027
3291
  if (/^(?:while|until)(?:\s|$)/.test(trimmed)) {
3292
+ const compoundStart = i;
3028
3293
  const kind = trimmed.startsWith("until") ? "until" : "while";
3029
3294
  let header = trimmed;
3030
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3295
+ while (!/(?:;\s*|\s+)do\s*$/.test(header) && ++i < lines.length)
3031
3296
  header += ` ${lines[i].trim()}`;
3032
- if (!/;\s*do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3033
- const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/;\s*do\s*$/, "");
3034
- const body = parseSequence(i + 1, [/^done(?:\s*<\s*.+)?\s*;?$/]);
3035
- const done = /^done(?:\s*<\s*(.+?))?\s*;?$/.exec(lines[body.index]?.trim() ?? "");
3297
+ if (!/(?:;\s*|\s+)do\s*$/.test(header)) throw new ShellSyntaxError(`${kind} requires do`);
3298
+ const condition = header.replace(new RegExp(`^${kind}\\s*`), "").replace(/(?:;\s*|\s+)do\s*$/, "");
3299
+ const body = parseSequence(i + 1, [/^done\b/]);
3300
+ const done = /^done(?:\s*<\s*(.+?))?\s*;?\s*((?:\|\||&&|\||;).*)?$/.exec(lines[body.index]?.trim() ?? "");
3036
3301
  if (!done)
3037
3302
  throw new ShellSyntaxError(`unterminated ${kind}`);
3038
- nodes.push({ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null });
3039
- i = body.index + 1;
3303
+ const compound = {
3304
+ type: "loop", kind, condition, body: body.nodes, input: done[1] ?? null,
3305
+ source: [...lines.slice(compoundStart, body.index), `done${done[1] ? ` < ${done[1]}` : ""}`].join("\n"),
3306
+ };
3307
+ const result = makeCompoundPostfix(compound, done[2] ?? "", lines, body.index + 1);
3308
+ nodes.push(result.node);
3309
+ i = result.nextLine;
3040
3310
  continue;
3041
3311
  }
3042
3312
  if (/^for(?:\s|$)/.test(trimmed)) {
3313
+ const compoundStart = i;
3043
3314
  let header = trimmed;
3044
- while (!/;\s*do\s*$/.test(header) && ++i < lines.length)
3315
+ let bodyStart = -1;
3316
+ while (i < lines.length) {
3317
+ if (/;\s*do\s*$/.test(header)) {
3318
+ header = header.replace(/;\s*do\s*$/, "");
3319
+ bodyStart = i + 1;
3320
+ break;
3321
+ }
3322
+ if (lines[i + 1]?.trim() === "do") {
3323
+ bodyStart = i + 2;
3324
+ i++;
3325
+ break;
3326
+ }
3327
+ if (++i >= lines.length) break;
3045
3328
  header += ` ${lines[i].trim()}`;
3046
- const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in\s+(.*?))?\s*;\s*do\s*$/.exec(header);
3329
+ }
3330
+ const match = /^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+in(?:\s+(.*?))?)?\s*;?$/.exec(header);
3047
3331
  if (!match) throw new ShellSyntaxError("invalid for command");
3048
- const body = parseSequence(i + 1, [/^done\s*;?$/]);
3049
- if (!/^done\s*;?$/.test(lines[body.index]?.trim() ?? ""))
3050
- throw new ShellSyntaxError("unterminated for");
3051
- nodes.push({ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes });
3052
- i = body.index + 1;
3332
+ if (bodyStart < 0) throw new ShellSyntaxError("for requires do");
3333
+ const body = parseSequence(bodyStart, [/^done\b/]);
3334
+ if (!/^done\b/.test(lines[body.index]?.trim() ?? ""))
3335
+ throw new ShellSyntaxError(`unterminated for: ${lines[compoundStart]?.trim()} (stopped at ${lines[body.index]?.trim()})`);
3336
+ const compound = {
3337
+ type: "for", name: match[1], words: match[2] ?? null, body: body.nodes,
3338
+ source: [...lines.slice(compoundStart, body.index), "done"].join("\n"),
3339
+ };
3340
+ const close = /^done\b(.*)$/.exec(lines[body.index].trim());
3341
+ const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, body.index + 1);
3342
+ nodes.push(result.node);
3343
+ i = result.nextLine;
3053
3344
  continue;
3054
3345
  }
3055
3346
  if (/^case(?:\s|$)/.test(trimmed)) {
3347
+ const compoundStart = i;
3056
3348
  let header = trimmed;
3057
- while (!/\s+in\s*$/.test(header) && ++i < lines.length)
3349
+ while (!/\s+in(?:\s|$)/.test(header) && ++i < lines.length)
3058
3350
  header += ` ${lines[i].trim()}`;
3059
- const match = /^case\s+(.+?)\s+in\s*$/.exec(header);
3351
+ const match = /^case\s+(.+?)\s+in(?:\s+(.*))?$/.exec(header);
3060
3352
  if (!match) throw new ShellSyntaxError("case requires in");
3061
- const arms = [];
3353
+ const inlineEsac = findShellKeyword(match[2] ?? "", "esac");
3354
+ if (inlineEsac >= 0) {
3355
+ const inlineArms = match[2].slice(0, inlineEsac);
3356
+ const suffix = match[2].slice(inlineEsac + "esac".length);
3357
+ const compound = {
3358
+ type: "case",
3359
+ word: match[1],
3360
+ arms: parseCaseArms(inlineArms),
3361
+ source: `case ${match[1]} in\n${inlineArms}\nesac`,
3362
+ };
3363
+ const result = makeCompoundPostfix(compound, suffix, lines, i + 1);
3364
+ nodes.push(result.node);
3365
+ i = result.nextLine;
3366
+ continue;
3367
+ }
3368
+ const bodyLines = match[2] ? [match[2]] : [];
3062
3369
  i++;
3063
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
3064
- if (!lines[i].trim() || lines[i].trim().startsWith("#")) { i++; continue; }
3065
- const arm = /^(.+?)\)\s*$/.exec(lines[i].trim());
3066
- if (!arm) throw new ShellSyntaxError("case pattern requires )");
3067
- const bodyLines = [];
3068
- i++;
3069
- while (i < lines.length && !/^esac\s*;?$/.test(lines[i].trim())) {
3070
- const current = lines[i];
3071
- const terminator = current.indexOf(";;");
3072
- if (terminator >= 0) {
3073
- if (current.slice(0, terminator).trim()) bodyLines.push(current.slice(0, terminator));
3074
- i++;
3075
- break;
3076
- }
3077
- bodyLines.push(current);
3078
- i++;
3370
+ let nestedCases = 0;
3371
+ while (i < lines.length) {
3372
+ const current = lines[i].trim();
3373
+ const nestedCase = findShellKeyword(current, "case");
3374
+ if (nestedCase >= 0 && /\s/.test(current[nestedCase + "case".length] ?? ""))
3375
+ nestedCases++;
3376
+ // In an enclosing case arm, mksh tokenises `esac;;` as two
3377
+ // independent tokens: ESAC followed by the enclosing arm's BREAK.
3378
+ // Count that as the end of the nested case while retaining the
3379
+ // whole line for parseCaseArms to assign the `;;` to its parent.
3380
+ const closingCase = findShellKeyword(current, "esac");
3381
+ if (closingCase >= 0) {
3382
+ if (!nestedCases) break;
3383
+ nestedCases--;
3079
3384
  }
3080
- arms.push({ patterns: arm[1].split("|").map((pattern) => pattern.trim()), body: parseCompoundScript(bodyLines.join("\n")) });
3385
+ bodyLines.push(lines[i++]);
3081
3386
  }
3082
- if (!/^esac\s*;?$/.test(lines[i]?.trim() ?? "")) throw new ShellSyntaxError("unterminated case");
3083
- nodes.push({ type: "case", word: match[1], arms });
3084
- i++;
3387
+ if (!/^esac\b/.test(lines[i]?.trim() ?? ""))
3388
+ throw new ShellSyntaxError(`unterminated case: ${trimmed} (at ${lines[i]?.trim() ?? "EOF"})`);
3389
+ const compound = {
3390
+ type: "case", word: match[1], arms: parseCaseArms(bodyLines.join("\n")),
3391
+ source: [...lines.slice(compoundStart, i), "esac"].join("\n"),
3392
+ };
3393
+ const close = /^esac\b(.*)$/.exec(lines[i].trim());
3394
+ const result = makeCompoundPostfix(compound, close?.[1] ?? "", lines, i + 1);
3395
+ nodes.push(result.node);
3396
+ i = result.nextLine;
3085
3397
  continue;
3086
3398
  }
3087
- const grown = growHeredocLine(lines, i);
3399
+ const grown = growLogicalCommand(lines, i);
3088
3400
  nodes.push({ type: "command", source: grown.source });
3089
3401
  i = grown.next;
3090
3402
  }
@@ -3135,6 +3447,61 @@ async function executeNodeList(nodes, state, options = {}) {
3135
3447
  };
3136
3448
  for (const node of nodes) {
3137
3449
  if (state.exitRequested) break;
3450
+ if (node.type === "compoundPostfix") {
3451
+ if (node.operator === "|") {
3452
+ const link = new TransformStream();
3453
+ const leftPromise = runExternal(
3454
+ pipelineCompoundArgv(node.compound.source),
3455
+ pipelineBuiltinState(state),
3456
+ options.input ?? null,
3457
+ false,
3458
+ true,
3459
+ link.writable,
3460
+ { pipelineKillSignal: "SIGTERM" },
3461
+ );
3462
+ const rightState = {
3463
+ ...state,
3464
+ env: { ...state.env },
3465
+ aliases: { ...state.aliases },
3466
+ functions: { ...state.functions },
3467
+ readonly: new Set(state.readonly),
3468
+ directoryHistory: [...state.directoryHistory],
3469
+ tabs: [...state.tabs],
3470
+ exitRequested: false,
3471
+ exitStatus: 0,
3472
+ };
3473
+ const right = await runExternal(
3474
+ pipelineCompoundArgv(node.command),
3475
+ pipelineBuiltinState(rightState),
3476
+ link.readable,
3477
+ true,
3478
+ true,
3479
+ );
3480
+ const left = await leftPromise;
3481
+ execution = result(right.status, right.stdout, concatBytes([left.stderr, right.stderr]));
3482
+ } else {
3483
+ execution = await executeNodeList([node.compound], state, { capture });
3484
+ append(execution);
3485
+ const shouldRun = node.operator === "&&"
3486
+ ? execution.status === 0
3487
+ : node.operator === "||"
3488
+ ? execution.status !== 0
3489
+ : true;
3490
+ if (shouldRun) {
3491
+ execution = await execute(node.command, state, { capture });
3492
+ append(execution);
3493
+ }
3494
+ state.lastStatus = execution.status;
3495
+ continue;
3496
+ }
3497
+ state.lastStatus = execution.status;
3498
+ if (capture) append(execution);
3499
+ else {
3500
+ if (execution.stdout.byteLength) stdout.push(execution.stdout);
3501
+ if (execution.stderr.byteLength) stderr.push(execution.stderr);
3502
+ }
3503
+ continue;
3504
+ }
3138
3505
  if (node.type === "function") {
3139
3506
  state.functions[node.name] = node.body;
3140
3507
  continue;
@@ -3218,29 +3585,39 @@ async function executeNodeList(nodes, state, options = {}) {
3218
3585
  const parsed = parse(`__case ${node.word}`)[0]?.pipeline[0];
3219
3586
  const value = (await expandWord(parsed.words[1], state, { single: true }))[0] ?? "";
3220
3587
  execution = result();
3588
+ let fallThrough = false;
3221
3589
  for (const arm of node.arms) {
3222
- let matches = false;
3223
- for (let pattern of arm.patterns) {
3224
- pattern = pattern.replace(/^(['"])(.*)\1$/, "$2");
3225
- pattern = stripExpansionMarkers(await expandText(pattern, state));
3226
- if (globPatternRegex(pattern, true, true, true).test(value)) { matches = true; break; }
3590
+ let matches = fallThrough;
3591
+ if (!matches) for (const pattern of arm.patterns) {
3592
+ if ((await casePatternRegex(pattern, state)).test(value)) { matches = true; break; }
3227
3593
  }
3228
3594
  if (!matches) continue;
3229
3595
  execution = await executeNodeList(arm.body, state, { capture });
3230
3596
  append(execution);
3597
+ if (arm.terminator === ";&") { fallThrough = true; continue; }
3598
+ if (arm.terminator === ";|") { fallThrough = false; continue; }
3231
3599
  break;
3232
3600
  }
3233
3601
  state.lastStatus = execution.status;
3234
3602
  continue;
3235
3603
  }
3236
3604
  let selected = null;
3605
+ let selectedThen = "";
3237
3606
  for (const branch of node.branches) {
3238
3607
  let condition = branch.condition.trim();
3239
3608
  let negate = false;
3240
3609
  while (/^!\s+/.test(condition)) { negate = !negate; condition = condition.replace(/^!\s+/, ""); }
3241
3610
  const checked = await execute(condition, state, { capture, simple: true });
3242
3611
  append(checked);
3243
- if ((checked.status === 0) !== negate) { selected = branch.body; break; }
3612
+ if ((checked.status === 0) !== negate) {
3613
+ selected = branch.body;
3614
+ selectedThen = branch.thenCommand ?? "";
3615
+ break;
3616
+ }
3617
+ }
3618
+ if (selectedThen) {
3619
+ const initial = await execute(selectedThen, state, { capture });
3620
+ append(initial);
3244
3621
  }
3245
3622
  execution = selected
3246
3623
  ? await executeNodeList(selected, state, { capture })
@@ -3359,6 +3736,85 @@ function pipelineBuiltinArgv(argv) {
3359
3736
  : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-cc", ...argv];
3360
3737
  }
3361
3738
 
3739
+ function pipelineCompoundArgv(source) {
3740
+ return IS_COMPILED
3741
+ ? [...EXECUTABLE_COMMAND, "-c", source]
3742
+ : [...BUN_RUNTIME_COMMAND, BUNMSH_ENTRY, "-c", source];
3743
+ }
3744
+
3745
+ function leadingSubshell(source) {
3746
+ const leading = source.length - source.trimStart().length;
3747
+ if (source[leading] !== "(") return null;
3748
+ let depth = 0, quote = null;
3749
+ for (let i = leading; i < source.length; i++) {
3750
+ const ch = source[i];
3751
+ if (quote === "'") { if (ch === "'") quote = null; continue; }
3752
+ if (quote === '"') {
3753
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) { i++; continue; }
3754
+ if (ch === '"') quote = null;
3755
+ continue;
3756
+ }
3757
+ if (ch === "\\") { i++; continue; }
3758
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
3759
+ if (ch === "`") {
3760
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
3761
+ continue;
3762
+ }
3763
+ if (ch === "$" && source[i + 1] === "(") {
3764
+ try { i = substitutionEnd(source, i) - 1; } catch { return null; }
3765
+ continue;
3766
+ }
3767
+ if (ch === "(") depth++;
3768
+ else if (ch === ")" && --depth === 0)
3769
+ return { body: source.slice(leading + 1, i), suffix: source.slice(i + 1) };
3770
+ }
3771
+ return null;
3772
+ }
3773
+
3774
+ function rewriteSubshells(source, state) {
3775
+ let output = "", quote = null;
3776
+ for (let i = 0; i < source.length;) {
3777
+ const ch = source[i];
3778
+ if (quote === "'") {
3779
+ output += ch;
3780
+ if (ch === "'") quote = null;
3781
+ i++;
3782
+ continue;
3783
+ }
3784
+ if (quote === '"') {
3785
+ output += ch;
3786
+ if (ch === "\\" && /[\\"$`\n]/.test(source[i + 1] ?? "")) output += source[++i];
3787
+ else if (ch === '"') quote = null;
3788
+ i++;
3789
+ continue;
3790
+ }
3791
+ if (ch === "\\") { output += source.slice(i, i + 2); i += 2; continue; }
3792
+ if (ch === "'" || ch === '"') { quote = ch; output += ch; i++; continue; }
3793
+ if (ch === "`") {
3794
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
3795
+ catch { output += ch; i++; continue; }
3796
+ }
3797
+ if (ch === "$" && source[i + 1] === "(") {
3798
+ try { const end = substitutionEnd(source, i); output += source.slice(i, end); i = end; continue; }
3799
+ catch { output += ch; i++; continue; }
3800
+ }
3801
+ const boundary = i === 0 || /[\s;|&]/.test(source[i - 1]);
3802
+ if (ch === "(" && boundary) {
3803
+ const group = leadingSubshell(source.slice(i));
3804
+ if (group) {
3805
+ const argv = pipelineCompoundArgv(group.body);
3806
+ const serialized = pipelineBuiltinState(state).env[PIPELINE_STATE_ENV];
3807
+ output += `BUNMSH_INTERNAL_PIPELINE_STATE=${quoteShellWord(serialized)} ${argv.map(quoteShellWord).join(" ")}`;
3808
+ i += group.body.length + 2;
3809
+ continue;
3810
+ }
3811
+ }
3812
+ output += ch;
3813
+ i++;
3814
+ }
3815
+ return output;
3816
+ }
3817
+
3362
3818
  function pipelineBuiltinState(state) {
3363
3819
  return {
3364
3820
  ...state,
@@ -3667,6 +4123,7 @@ async function runCommand(command, state, options = {}) {
3667
4123
  for (const redirect of command.redirects) {
3668
4124
  if (redirect.op === "1>&2") { stdoutToStderr = true; continue; }
3669
4125
  if (redirect.op === "2>&1") { stderrToStdout = true; continue; }
4126
+ if (/^\d+>&\d+$/.test(redirect.op)) continue;
3670
4127
  if (redirect.op === "<<" || redirect.op === "<<-") {
3671
4128
  const heredoc = redirect.heredoc ?? { body: "", expand: false };
3672
4129
  const content = heredoc.expand
@@ -3777,6 +4234,8 @@ async function runPipeline(pipeline, state, options = {}) {
3777
4234
  return result(2, "", "bunmsh: &: only a single external command without redirections is supported\n");
3778
4235
  if (pipeline.length === 1)
3779
4236
  return runCommand(pipeline[0], state, {
4237
+ input: options.input ?? null,
4238
+ stdoutSink: options.stdoutSink ?? null,
3780
4239
  captureStdout: Boolean(options.capture),
3781
4240
  captureStderr: Boolean(options.capture),
3782
4241
  background: Boolean(options.background),
@@ -3812,7 +4271,7 @@ async function runPipeline(pipeline, state, options = {}) {
3812
4271
  const stages = pipeline.map((command, i) => {
3813
4272
  const last = i === pipeline.length - 1;
3814
4273
  return runCommand(command, state, {
3815
- input: i === 0 ? null : links[i - 1].readable,
4274
+ input: i === 0 ? options.input ?? null : links[i - 1].readable,
3816
4275
  captureStdout: last && Boolean(options.capture),
3817
4276
  captureStderr: Boolean(options.capture),
3818
4277
  stdoutSink: last ? null : links[i].writable,
@@ -3860,6 +4319,47 @@ export async function execute(source, state = createState(), io = {}) {
3860
4319
  return execution;
3861
4320
  }
3862
4321
  const rawSource = source.trimStart();
4322
+ const group = leadingSubshell(source);
4323
+ if (group) {
4324
+ let suffix = group.suffix;
4325
+ let discardStdout = false, discardStderr = false;
4326
+ while (true) {
4327
+ const redirect = /^\s*(?:(\d+)?(>>?|<)\s*([^\s;&|]+)|(\d+>&\d+))/.exec(suffix);
4328
+ if (!redirect) break;
4329
+ if (redirect[2] === ">" || redirect[2] === ">>") {
4330
+ if (redirect[1] === "2") discardStderr ||= redirect[3] === "/dev/null";
4331
+ else discardStdout ||= redirect[3] === "/dev/null";
4332
+ }
4333
+ suffix = suffix.slice(redirect[0].length);
4334
+ }
4335
+ const child = await runExternal(
4336
+ pipelineCompoundArgv(group.body),
4337
+ pipelineBuiltinState(state),
4338
+ io.input ?? null,
4339
+ true,
4340
+ true,
4341
+ );
4342
+ let execution = result(child.status,
4343
+ discardStdout ? "" : child.stdout,
4344
+ discardStderr ? "" : child.stderr);
4345
+ const connector = /^\s*(\|\||&&|;)\s*([\s\S]*)$/.exec(suffix);
4346
+ if (connector) {
4347
+ const shouldRun = connector[1] === ";" ||
4348
+ (connector[1] === "&&" ? execution.status === 0 : execution.status !== 0);
4349
+ if (shouldRun) {
4350
+ const tail = await execute(connector[2], state, io);
4351
+ execution = result(tail.status,
4352
+ concatBytes([execution.stdout, tail.stdout]),
4353
+ concatBytes([execution.stderr, tail.stderr]));
4354
+ }
4355
+ }
4356
+ state.lastStatus = execution.status;
4357
+ if (!io.capture) {
4358
+ if (execution.stdout.byteLength) await writeStream(process.stdout, execution.stdout);
4359
+ if (execution.stderr.byteLength) await writeStream(process.stderr, execution.stderr);
4360
+ }
4361
+ return execution;
4362
+ }
3863
4363
  if (isJavaScriptMode(rawSource)) {
3864
4364
  let execution;
3865
4365
  const previousCwd = process.cwd();
@@ -3930,6 +4430,7 @@ export async function execute(source, state = createState(), io = {}) {
3930
4430
  try {
3931
4431
  execution = await executeNodeList(parseCompoundScript(source), state, {
3932
4432
  capture: Boolean(io.capture),
4433
+ input: io.input ?? null,
3933
4434
  });
3934
4435
  }
3935
4436
  catch (error) {
@@ -3944,6 +4445,12 @@ export async function execute(source, state = createState(), io = {}) {
3944
4445
  return execution;
3945
4446
  }
3946
4447
 
4448
+ const withSubshells = rewriteSubshells(source, state);
4449
+ if (withSubshells !== source) {
4450
+ source = withSubshells;
4451
+ return execute(source, state, io);
4452
+ }
4453
+
3947
4454
  let jobs;
3948
4455
  try {
3949
4456
  jobs = parse(source);
@@ -3965,6 +4472,8 @@ export async function execute(source, state = createState(), io = {}) {
3965
4472
  execution = await runPipeline(job.pipeline, state, {
3966
4473
  capture: io.capture,
3967
4474
  background: job.background,
4475
+ input: io.input ?? null,
4476
+ stdoutSink: io.stdoutSink ?? null,
3968
4477
  });
3969
4478
  if (job.negate) execution.status = execution.status === 0 ? 1 : 0;
3970
4479
  state.lastStatus = execution.status;