buninu-linux 0.7.2 → 0.7.3

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.
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.18 - 2026-09-29
4
+
5
+ - Sync jsgotty to 1.1.14: Kitty for Windows
6
+ - Sync bunmsh to 0.3.10: Fix several posix/gnu/external builtin commands
7
+
3
8
  ## 0.4.17 - 2026-09-26
4
9
 
5
10
  - readme: Add homepage URL
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable user-visible changes to bunmsh are documented here.
4
4
 
5
+ ## [0.3.10] - 2026-09-29
6
+
7
+ ### Fixed
8
+
9
+ - Match system utilities when handling multiple operands: `printf` repeats its
10
+ format for remaining arguments; `ln` links multiple sources into a directory;
11
+ `head`, `tail`, `wc`, and `cut` process each file separately; and `md5sum` and
12
+ `sha256sum` print one labelled digest per file. `wc` also prints a total for
13
+ multiple files. Reference tests compare these results with system commands.
14
+
15
+ - Make `grep -r PATTERN` without an explicit path search the current directory,
16
+ as system `grep` does, instead of reading a locked standard-input stream.
17
+ Reference tests cover recursive matches, line numbers, no matches, and quiet
18
+ mode.
19
+
5
20
  ## [0.3.9] - 2026-09-22
6
21
 
7
22
  ### Fixed
@@ -32,8 +32,12 @@ pseudo-terminal tests.
32
32
 
33
33
  ### Grammar and execution
34
34
 
35
- - Background jobs (`&`), job control, coprocesses, and asynchronous command
36
- lists are not implemented.
35
+ - A final, standalone, unquoted `&` starts a single external command in the
36
+ background (trailing comments and newlines are allowed). Stdin is ignored;
37
+ stdout/stderr are inherited, and the process is exposed in `Bun.sha.procs`.
38
+ Background pipelines, builtins, functions, and all redirections are rejected
39
+ before files are opened. General asynchronous command lists, job control,
40
+ and coprocesses are not implemented.
37
41
  - Brace command groups, `select`, arithmetic commands, and `[[ ... ]]` are
38
42
  not implemented.
39
43
  - Functions and subshells cover practical cases but do not yet reproduce every
@@ -959,7 +959,8 @@ Create hard or symbolic links.
959
959
  ### Usage
960
960
 
961
961
  ```sh
962
- ln [-sfT] TARGET LINK
962
+ ln [-sf] TARGET LINK
963
+ ln [-sf] TARGET ... DIRECTORY
963
964
  ```
964
965
 
965
966
  ### Options and forms
@@ -967,6 +968,7 @@ ln [-sfT] TARGET LINK
967
968
  - `-s`: Create a symbolic link.
968
969
  - `-f`: Replace an existing destination.
969
970
  - `-T`: Treat the destination as a normal path, not a directory.
971
+ - Multiple targets may be linked into an existing directory.
970
972
 
971
973
  ### Example
972
974
 
@@ -5,7 +5,8 @@ Create hard or symbolic links.
5
5
  ### Usage
6
6
 
7
7
  ```sh
8
- ln [-sfT] TARGET LINK
8
+ ln [-sf] TARGET LINK
9
+ ln [-sf] TARGET ... DIRECTORY
9
10
  ```
10
11
 
11
12
  ### Options and forms
@@ -13,6 +14,7 @@ ln [-sfT] TARGET LINK
13
14
  - `-s`: Create a symbolic link.
14
15
  - `-f`: Replace an existing destination.
15
16
  - `-T`: Treat the destination as a normal path, not a directory.
17
+ - Multiple targets may be linked into an existing directory.
16
18
 
17
19
  ### Example
18
20
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmsh",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Cross-platform command shell for Bun JavaScript",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -283,7 +283,6 @@ export function tokenize(source, options = {}) {
283
283
  while (i < source.length && source[i] !== "\n") i++;
284
284
  continue;
285
285
  }
286
-
287
286
  const two = source.slice(i, i + 2);
288
287
  const fdDup = source.slice(i, i + 4);
289
288
  const operatorAhead =
@@ -448,6 +447,7 @@ function emptyCommand() {
448
447
  }
449
448
 
450
449
  export function parse(source) {
450
+ source = normalizeSource(source);
451
451
  const tokens = tokenize(source);
452
452
  const jobs = [];
453
453
  let i = 0;
@@ -513,8 +513,8 @@ export function parse(source) {
513
513
  throw new ShellSyntaxError("! requires a command", firstWord.offset);
514
514
  negate = true;
515
515
  }
516
- jobs.push({ connector, pipeline, negate });
517
516
  const separator = tokens[i]?.value;
517
+ jobs.push({ connector, pipeline, negate, background: false });
518
518
  if (!separator) break;
519
519
  if (![";", "&&", "||"].includes(separator))
520
520
  throw new ShellSyntaxError(`unexpected operator ${separator}`, tokens[i].offset);
@@ -523,6 +523,20 @@ export function parse(source) {
523
523
  skipSemicolons();
524
524
  if (i >= tokens.length) break;
525
525
  }
526
+ const lastJob = jobs.at(-1);
527
+ const lastCommand = lastJob?.pipeline.at(-1);
528
+ const lastWord = lastCommand?.words.at(-1);
529
+ const lastToken = tokens.findLast((token) =>
530
+ token.type !== "op" || token.value !== ";" || source[token.offset] !== "\n");
531
+ if (lastWord === lastToken &&
532
+ lastWord?.fragments.length === 1 &&
533
+ lastWord.fragments[0].quote === "none" &&
534
+ lastWord.fragments[0].text === "&") {
535
+ lastCommand.words.pop();
536
+ if (lastCommand.words.length === 0 && lastCommand.redirects.length === 0)
537
+ throw new ShellSyntaxError("expected a command", lastWord.offset);
538
+ lastJob.background = true;
539
+ }
526
540
  return jobs;
527
541
  }
528
542
 
@@ -1223,33 +1237,37 @@ const builtins = {
1223
1237
  const format = argv[1];
1224
1238
  let ai = 2;
1225
1239
  let output = "";
1226
- for (let i = 0; i < format.length; i++) {
1227
- if (format[i] === "\\" && i + 1 < format.length) {
1228
- const parsed = parsePrintEscapes(format.slice(i, i + 2));
1229
- output += parsed.text;
1230
- i++;
1231
- continue;
1232
- }
1233
- if (format[i] !== "%") {
1234
- output += format[i];
1235
- continue;
1236
- }
1237
- if (format[i + 1] === "%") {
1238
- output += "%";
1239
- i++;
1240
- continue;
1240
+ let previous;
1241
+ do {
1242
+ previous = ai;
1243
+ for (let i = 0; i < format.length; i++) {
1244
+ if (format[i] === "\\" && i + 1 < format.length) {
1245
+ const parsed = parsePrintEscapes(format.slice(i, i + 2));
1246
+ output += parsed.text;
1247
+ i++;
1248
+ continue;
1249
+ }
1250
+ if (format[i] !== "%") {
1251
+ output += format[i];
1252
+ continue;
1253
+ }
1254
+ if (format[i + 1] === "%") {
1255
+ output += "%";
1256
+ i++;
1257
+ continue;
1258
+ }
1259
+ const match = /^%([0-9]*)([sdi])/.exec(format.slice(i));
1260
+ if (!match) return result(2, "", `bunmsh: printf: unsupported format near ${format.slice(i)}\n`);
1261
+ const width = Number(match[1] || 0);
1262
+ const kind = match[2];
1263
+ const value = argv[ai++] ?? (kind === "s" ? "" : "0");
1264
+ let rendered = kind === "s" ? value : String(Number.parseInt(value, 10) || 0);
1265
+ if (width > rendered.length)
1266
+ rendered = (match[1]?.startsWith("0") ? "0" : " ").repeat(width - rendered.length) + rendered;
1267
+ output += rendered;
1268
+ i += match[0].length - 1;
1241
1269
  }
1242
- const match = /^%([0-9]*)([sdi])/.exec(format.slice(i));
1243
- if (!match) return result(2, "", `bunmsh: printf: unsupported format near ${format.slice(i)}\n`);
1244
- const width = Number(match[1] || 0);
1245
- const kind = match[2];
1246
- const value = argv[ai++] ?? (kind === "s" ? "" : "0");
1247
- let rendered = kind === "s" ? value : String(Number.parseInt(value, 10) || 0);
1248
- if (width > rendered.length)
1249
- rendered = (match[1]?.startsWith("0") ? "0" : " ").repeat(width - rendered.length) + rendered;
1250
- output += rendered;
1251
- i += match[0].length - 1;
1252
- }
1270
+ } while (ai > previous && ai < argv.length);
1253
1271
  return result(0, output);
1254
1272
  },
1255
1273
  read: async (argv, state, input) => {
@@ -1821,11 +1839,19 @@ async function runHeadFallback(argv, state, input) {
1821
1839
  return result(1, "", "bunmsh: head: invalid line count\n");
1822
1840
  const operands = argv.slice(i);
1823
1841
  if (operands.length) {
1824
- try {
1825
- const data = await readFallbackFiles(operands, state, input);
1826
- if (bytesMode) return result(0, data.slice(0, count));
1827
- return result(0, decoder.decode(data).split(/(?<=\n)/).slice(0, count).join(""));
1828
- } catch (error) { return result(1, "", `bunmsh: head: ${error.message}\n`); }
1842
+ const output = [];
1843
+ let status = 0, stderr = "";
1844
+ for (const operand of operands) {
1845
+ if (operands.length > 1)
1846
+ output.push(`${output.length ? "\n" : ""}==> ${operand} <==\n`);
1847
+ try {
1848
+ const data = await readFallbackFiles([operand], state, input);
1849
+ output.push(bytesMode
1850
+ ? data.slice(0, count)
1851
+ : decoder.decode(data).split(/(?<=\n)/).slice(0, count).join(""));
1852
+ } catch (error) { status = 1; stderr += `bunmsh: head: ${operand}: ${error.message}\n`; }
1853
+ }
1854
+ return result(status, concatBytes(output), stderr);
1829
1855
  }
1830
1856
  const reader = fallbackInput(input).getReader();
1831
1857
  const output = [];
@@ -1873,19 +1899,32 @@ async function runTextFilter(argv, state, input, kind) {
1873
1899
  reverse ||= option.includes("r"); numeric ||= option.includes("n"); unique ||= option.includes("u");
1874
1900
  }
1875
1901
  }
1876
- const text = decoder.decode(await readFallbackFiles(args, state, input));
1877
- let lines = text.split("\n");
1878
- const trailing = lines.at(-1) === "";
1879
- if (trailing) lines.pop();
1880
1902
  if (kind === "tail") {
1881
- lines = tailFromStart
1882
- ? lines.slice(Math.max(0, tailCount - 1))
1883
- : lines.slice(-tailCount);
1884
- } else {
1885
- lines.sort((a, b) => numeric ? Number(a) - Number(b) : a.localeCompare(b));
1886
- if (unique) lines = lines.filter((line, index) => index === 0 || line !== lines[index - 1]);
1887
- if (reverse) lines.reverse();
1903
+ const operands = args.length ? args : ["-"];
1904
+ const output = [];
1905
+ let status = 0, stderr = "";
1906
+ for (const operand of operands) {
1907
+ try {
1908
+ const text = decoder.decode(await readFallbackFiles([operand], state, input));
1909
+ const lines = text.split("\n");
1910
+ const trailing = lines.at(-1) === "";
1911
+ if (trailing) lines.pop();
1912
+ const selected = tailFromStart
1913
+ ? lines.slice(Math.max(0, tailCount - 1))
1914
+ : tailCount === 0 ? [] : lines.slice(-tailCount);
1915
+ if (operands.length > 1 && (tailFromStart || tailCount !== 0))
1916
+ output.push(`${output.length ? "\n" : ""}==> ${operand === "-" ? "standard input" : operand} <==\n`);
1917
+ if (selected.length) output.push(`${selected.join("\n")}${trailing ? "\n" : ""}`);
1918
+ } catch (error) { status = 1; stderr += `bunmsh: tail: ${operand}: ${error.message}\n`; }
1919
+ }
1920
+ return result(status, output.join(""), stderr);
1888
1921
  }
1922
+ const text = decoder.decode(await readFallbackFiles(args, state, input));
1923
+ let lines = text.split("\n");
1924
+ if (lines.at(-1) === "") lines.pop();
1925
+ lines.sort((a, b) => numeric ? Number(a) - Number(b) : a.localeCompare(b));
1926
+ if (unique) lines = lines.filter((line, index) => index === 0 || line !== lines[index - 1]);
1927
+ if (reverse) lines.reverse();
1889
1928
  return result(0, lines.length ? `${lines.join("\n")}\n` : "");
1890
1929
  } catch (error) { return result(1, "", `bunmsh: ${kind}: ${error.message}\n`); }
1891
1930
  }
@@ -1894,15 +1933,27 @@ async function runWcFallback(argv, state, input) {
1894
1933
  let flags = "", i = 1;
1895
1934
  while (/^-[lwc]+$/.test(argv[i] ?? "")) flags += argv[i++].slice(1);
1896
1935
  if (!flags) flags = "lwc";
1897
- try {
1898
- const data = await readFallbackFiles(argv.slice(i), state, input);
1936
+ const operands = argv.slice(i);
1937
+ const files = operands.length ? operands : ["-"];
1938
+ const rows = [];
1939
+ let status = 0, stderr = "";
1940
+ let totalBytes = 0;
1941
+ for (const operand of files) try {
1942
+ const data = await readFallbackFiles([operand], state, input);
1899
1943
  const text = decoder.decode(data);
1900
1944
  const values = [];
1901
1945
  if (flags.includes("l")) values.push((text.match(/\n/g) ?? []).length);
1902
1946
  if (flags.includes("w")) values.push(text.trim() ? text.trim().split(/\s+/).length : 0);
1903
1947
  if (flags.includes("c")) values.push(data.byteLength);
1904
- return result(0, `${values.join(" ")}\n`);
1905
- } catch (error) { return result(1, "", `bunmsh: wc: ${error.message}\n`); }
1948
+ totalBytes += data.byteLength;
1949
+ rows.push({ values, operand });
1950
+ } catch (error) { status = 1; stderr += `bunmsh: wc: ${operand}: ${error.message}\n`; }
1951
+ if (operands.length > 1 && rows.length)
1952
+ rows.push({ values: rows.reduce((total, row) => total.map((n, index) => n + row.values[index]),
1953
+ rows[0].values.map(() => 0)), operand: "total" });
1954
+ const width = operands.length > 1 ? String(totalBytes).length : 0;
1955
+ return result(status, rows.map(({ values, operand }) =>
1956
+ `${values.map((value) => String(value).padStart(width)).join(" ")}${operands.length ? ` ${operand}` : ""}\n`).join(""), stderr);
1906
1957
  }
1907
1958
 
1908
1959
  function expandTrSet(value) {
@@ -2007,11 +2058,14 @@ async function runTeeFallback(argv, state, input) {
2007
2058
 
2008
2059
  async function runHashFallback(argv, state, input, algorithm) {
2009
2060
  const operands = argv.slice(1);
2010
- try {
2011
- const data = await readFallbackFiles(operands, state, input);
2061
+ const output = [];
2062
+ let status = 0, stderr = "";
2063
+ for (const operand of operands.length ? operands : ["-"]) try {
2064
+ const data = await readFallbackFiles([operand], state, input);
2012
2065
  const digest = createHash(algorithm).update(data).digest("hex");
2013
- return result(0, `${digest}${operands.length === 1 && operands[0] !== "-" ? ` ${operands[0]}` : ""}\n`);
2014
- } catch (error) { return result(1, "", `bunmsh: ${algorithm}sum: ${error.message}\n`); }
2066
+ output.push(`${digest} ${operand}\n`);
2067
+ } catch (error) { status = 1; stderr += `bunmsh: ${algorithm}sum: ${operand}: ${error.message}\n`; }
2068
+ return result(status, output.join(""), stderr);
2015
2069
  }
2016
2070
 
2017
2071
  function runMktempFallback(argv, state) {
@@ -2173,6 +2227,8 @@ async function runGrepFallback(argv, state, input, context = {}) {
2173
2227
  };
2174
2228
 
2175
2229
  const operands = argv.slice(i);
2230
+ const implicitCwd = !operands.length && flags.includes("r");
2231
+ if (implicitCwd) operands.push(".");
2176
2232
  if (operands.length) {
2177
2233
  try {
2178
2234
  const files = grepFiles(operands, state, flags.includes("r"));
@@ -2181,7 +2237,8 @@ async function runGrepFallback(argv, state, input, context = {}) {
2181
2237
  const lines = (await Bun.file(file.path).text()).split("\n");
2182
2238
  if (lines.at(-1) === "") lines.pop();
2183
2239
  for (let line = 0; line < lines.length; line++)
2184
- if (processLine(lines[line], line + 1, file.shown, showLabel)) return result(0);
2240
+ if (processLine(lines[line], line + 1,
2241
+ implicitCwd ? file.shown.replace(/^\.\//, "") : file.shown, showLabel)) return result(0);
2185
2242
  }
2186
2243
  } catch (error) { return result(2, "", `bunmsh: grep: ${error.message}\n`); }
2187
2244
  } else {
@@ -2341,20 +2398,26 @@ function cutCharacterRanges(spec) {
2341
2398
  return ranges;
2342
2399
  }
2343
2400
 
2344
- async function runCutFallback(argv, _state, input) {
2401
+ async function runCutFallback(argv, state, input) {
2345
2402
  let spec, i = 1;
2346
2403
  if (argv[i] === "-c") spec = argv[++i], i++;
2347
2404
  else if (argv[i]?.startsWith("-c")) spec = argv[i++].slice(2);
2348
- if (!spec || i !== argv.length) return result(1, "", "bunmsh: cut: usage: cut -c LIST\n");
2405
+ if (!spec) return result(1, "", "bunmsh: cut: usage: cut -c LIST [FILE ...]\n");
2349
2406
  try {
2350
2407
  const ranges = cutCharacterRanges(spec);
2351
- const text = decoder.decode(await readFallbackInput(input));
2352
- const trailing = text.endsWith("\n");
2353
- const lines = text.split("\n");
2354
- if (trailing) lines.pop();
2355
- const output = lines.map((line) => [...line].filter((_ch, index) =>
2356
- ranges.some(([start, end]) => index + 1 >= start && index + 1 <= end)).join("")).join("\n");
2357
- return result(0, output + (trailing ? "\n" : ""));
2408
+ const output = [];
2409
+ let status = 0, stderr = "";
2410
+ const operands = argv.slice(i);
2411
+ for (const operand of operands.length ? operands : ["-"]) try {
2412
+ const text = decoder.decode(await readFallbackFiles([operand], state, input));
2413
+ const trailing = text.endsWith("\n");
2414
+ const lines = text.split("\n");
2415
+ if (trailing) lines.pop();
2416
+ output.push(lines.map((line) => [...line].filter((_ch, index) =>
2417
+ ranges.some(([start, end]) => index + 1 >= start && index + 1 <= end)).join("")).join("\n") +
2418
+ (trailing ? "\n" : ""));
2419
+ } catch (error) { status = 1; stderr += `bunmsh: cut: ${operand}: ${error.message}\n`; }
2420
+ return result(status, output.join(""), stderr);
2358
2421
  } catch (error) { return result(1, "", `bunmsh: cut: ${error.message}\n`); }
2359
2422
  }
2360
2423
 
@@ -2366,24 +2429,27 @@ function runLnFallback(argv, state) {
2366
2429
  noTargetDirectory ||= argv[i].includes("T");
2367
2430
  i++;
2368
2431
  }
2369
- if (argv.length - i !== 2) return result(1, "", "bunmsh: ln: usage: ln [-sf] target link_name\n");
2370
- const target = nativePath(argv[i]);
2371
- let link = isAbsolute(nativePath(argv[i + 1]))
2372
- ? nativePath(argv[i + 1])
2373
- : resolvePath(nativePath(state.cwd), nativePath(argv[i + 1]));
2374
- try {
2375
- if (!noTargetDirectory) {
2376
- try {
2377
- if (statSync(link).isDirectory()) link = resolvePath(link, pathBasename(target));
2378
- } catch {}
2379
- }
2380
- if (force) try { unlinkSync(link); } catch {}
2381
- if (symbolic) symlinkSync(target, link); else {
2382
- const source = isAbsolute(target) ? target : resolvePath(nativePath(state.cwd), target);
2383
- linkSync(source, link);
2384
- }
2385
- return result();
2386
- } catch (error) { return result(1, "", `bunmsh: ln: ${error.message}\n`); }
2432
+ if (argv.length - i < 2) return result(1, "", "bunmsh: ln: usage: ln [-sf] target... link_name\n");
2433
+ const sources = argv.slice(i, -1);
2434
+ const destination = nativePath(argv.at(-1));
2435
+ const targetPath = isAbsolute(destination) ? destination : resolvePath(nativePath(state.cwd), destination);
2436
+ let directory = false;
2437
+ if (!noTargetDirectory) try { directory = statSync(targetPath).isDirectory(); } catch {}
2438
+ if (sources.length > 1 && !directory)
2439
+ return result(1, "", "bunmsh: ln: target is not a directory\n");
2440
+ let status = 0, stderr = "";
2441
+ for (const operand of sources) {
2442
+ const target = nativePath(operand);
2443
+ const link = directory ? resolvePath(targetPath, pathBasename(target)) : targetPath;
2444
+ try {
2445
+ if (force) try { unlinkSync(link); } catch {}
2446
+ if (symbolic) symlinkSync(target, link); else {
2447
+ const source = isAbsolute(target) ? target : resolvePath(nativePath(state.cwd), target);
2448
+ linkSync(source, link);
2449
+ }
2450
+ } catch (error) { status = 1; stderr += `bunmsh: ln: ${operand}: ${error.message}\n`; }
2451
+ }
2452
+ return result(status, "", stderr);
2387
2453
  }
2388
2454
 
2389
2455
  function runChmodFallback(argv, state) {
@@ -3210,15 +3276,22 @@ async function runExternal(
3210
3276
  cmd: spawnArgv,
3211
3277
  cwd: nativePath(state.cwd),
3212
3278
  env: state.env,
3213
- stdin: input === null ? "inherit" : input,
3214
- stdout: captureStdout || stdoutSink ? "pipe" : "inherit",
3215
- stderr: captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
3279
+ stdin: runtimeOptions.background ? "ignore" : input === null ? "inherit" : input,
3280
+ stdout: runtimeOptions.background ? "inherit" : captureStdout || stdoutSink ? "pipe" : "inherit",
3281
+ stderr: runtimeOptions.background ? "inherit" : captureStderr || runtimeOptions.stderrSink ? "pipe" : "inherit",
3216
3282
  onExit(proc, exitCode, signalCode, error) {
3217
3283
  runtimeOptions.onExit?.(proc, exitCode, signalCode, error);
3218
3284
  },
3219
3285
  };
3220
3286
  try {
3221
3287
  const proc = Bun.spawn(options);
3288
+ if (runtimeOptions.background) {
3289
+ if (!Array.isArray(Bun.sha.procs)) Bun.sha.procs = [];
3290
+ Bun.sha.procs.push(proc);
3291
+ proc.unref();
3292
+ const index = Bun.sha.procs.length - 1;
3293
+ return result(0, "", `[${index + 1}] ${proc.pid} Bun.sha.procs[${index}]\n`);
3294
+ }
3222
3295
  const pipelineKillSignal = runtimeOptions.pipelineKillSignal ?? "SIGPIPE";
3223
3296
  // A downstream pipeline stage that closes early (for example `head -n 3`)
3224
3297
  // leaves nothing reading the other end of `stdoutSink`/`stderrSink` (a
@@ -3379,6 +3452,15 @@ async function runCommandArgv(
3379
3452
  if(argv[0] === "&")
3380
3453
  commandArgv = argv.slice(1) ;
3381
3454
  }
3455
+
3456
+ if (options.background && (
3457
+ options.pipelineStage ||
3458
+ ["command", "builtin", "__builtin", "time", "yes"].includes(commandArgv[0]) ||
3459
+ Object.hasOwn(state.functions, commandArgv[0]) ||
3460
+ Object.hasOwn(builtins, commandArgv[0]) ||
3461
+ (!commandArgv[0].includes("/") && Object.hasOwn(fallbackBuiltins, commandArgv[0]) &&
3462
+ !findExecutable(commandArgv[0], state))
3463
+ )) return result(2, "", "bunmsh: &: only a single external command is supported\n");
3382
3464
 
3383
3465
  let commandState = state;
3384
3466
  if (options.pipelineStage && runsInPipelineSubprocess(commandArgv, commandState))
@@ -3674,6 +3756,7 @@ async function runCommand(command, state, options = {}) {
3674
3756
  stderrSink: stderrRedirect,
3675
3757
  onSpawn: options.onSpawn,
3676
3758
  onExit: options.onExit,
3759
+ background: Boolean(options.background),
3677
3760
  },
3678
3761
  );
3679
3762
 
@@ -3690,10 +3773,13 @@ async function runCommand(command, state, options = {}) {
3690
3773
  }
3691
3774
 
3692
3775
  async function runPipeline(pipeline, state, options = {}) {
3776
+ if (options.background && (pipeline.length !== 1 || pipeline[0].redirects.length !== 0))
3777
+ return result(2, "", "bunmsh: &: only a single external command without redirections is supported\n");
3693
3778
  if (pipeline.length === 1)
3694
3779
  return runCommand(pipeline[0], state, {
3695
3780
  captureStdout: Boolean(options.capture),
3696
3781
  captureStderr: Boolean(options.capture),
3782
+ background: Boolean(options.background),
3697
3783
  });
3698
3784
 
3699
3785
  const links = Array.from(
@@ -3876,7 +3962,10 @@ export async function execute(source, state = createState(), io = {}) {
3876
3962
  for (const job of jobs) {
3877
3963
  if (job.connector === "&&" && execution.status !== 0) continue;
3878
3964
  if (job.connector === "||" && execution.status === 0) continue;
3879
- execution = await runPipeline(job.pipeline, state, { capture: io.capture });
3965
+ execution = await runPipeline(job.pipeline, state, {
3966
+ capture: io.capture,
3967
+ background: job.background,
3968
+ });
3880
3969
  if (job.negate) execution.status = execution.status === 0 ? 1 : 0;
3881
3970
  state.lastStatus = execution.status;
3882
3971
  if (execution.stdout.byteLength) {
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, mkdtempSync, readlinkSync, readdirSync, rmSync, statSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createState, decode, execute } from "../src/shell.js";
@@ -86,6 +86,15 @@ async function expectBuiltinSedLike(args, input, cwd = root) {
86
86
  expect(actual).toEqual(reference);
87
87
  }
88
88
 
89
+ async function expectBuiltinLikeSystem(name, args, input = "", cwd = root) {
90
+ const [actual, reference] = await Promise.all([
91
+ invokeArgvWithInput(
92
+ [process.execPath, join(root, "src/main.js"), "-cc", "builtin", name, ...args], input, cwd),
93
+ invokeArgvWithInput([Bun.which(name), ...args], input, cwd),
94
+ ]);
95
+ expect(actual).toEqual(reference);
96
+ }
97
+
89
98
  describe("/bin/sh reference", () => {
90
99
  describe("quote and word expansion", () => {
91
100
  test("removes LF and CRLF backslash continuations before tokenization", async () => {
@@ -402,6 +411,79 @@ printf ':%s' $?
402
411
  });
403
412
  });
404
413
 
414
+ describe("system multi-operand reference", () => {
415
+ test("printf repeats its format until all arguments are consumed", async () => {
416
+ await expectBuiltinLikeSystem("printf", ["%s=%d\n", "one", "1", "two", "2"]);
417
+ await expectBuiltinLikeSystem("printf", ["[%s][%s]\n", "one", "two", "three"]);
418
+ });
419
+
420
+ test("ln links each source into a target directory", async () => {
421
+ const directory = mkdtempSync(join(tmpdir(), "bunmsh-ln-reference-"));
422
+ const actualDir = join(directory, "actual");
423
+ const systemDir = join(directory, "system");
424
+ try {
425
+ for (const cwd of [actualDir, systemDir]) {
426
+ mkdirSync(cwd);
427
+ mkdirSync(join(cwd, "hard"));
428
+ mkdirSync(join(cwd, "soft"));
429
+ await Bun.write(join(cwd, "one"), "first\n");
430
+ await Bun.write(join(cwd, "two"), "second\n");
431
+ }
432
+ for (const args of [["one", "two", "hard"], ["-s", "one", "two", "soft"]]) {
433
+ const [actual, reference] = await Promise.all([
434
+ invokeArgvWithInput([process.execPath, join(root, "src/main.js"), "-cc", "builtin", "ln", ...args], "", actualDir),
435
+ invokeArgvWithInput([Bun.which("ln"), ...args], "", systemDir),
436
+ ]);
437
+ expect(actual).toEqual(reference);
438
+ }
439
+ for (const name of ["one", "two"]) {
440
+ expect(readdirSync(join(actualDir, "hard"))).toEqual(readdirSync(join(systemDir, "hard")));
441
+ expect(statSync(join(actualDir, "hard", name)).ino).toBe(statSync(join(actualDir, name)).ino);
442
+ expect(readlinkSync(join(actualDir, "soft", name)))
443
+ .toBe(readlinkSync(join(systemDir, "soft", name)));
444
+ }
445
+ } finally { rmSync(directory, { recursive: true, force: true }); }
446
+ });
447
+
448
+ test("head and tail select lines independently from each file", async () => {
449
+ const directory = mkdtempSync(join(tmpdir(), "bunmsh-parts-reference-"));
450
+ try {
451
+ await Bun.write(join(directory, "one"), "alpha\nbeta\n");
452
+ await Bun.write(join(directory, "two"), "gamma\ndelta\n");
453
+ for (const args of [["-n", "1", "one", "two"], ["-c", "2", "one", "two"],
454
+ ["-n", "0", "one", "two"]])
455
+ await expectBuiltinLikeSystem("head", args, "", directory);
456
+ for (const args of [["-n", "1", "one", "two"], ["-n", "+2", "one", "two"],
457
+ ["-n", "0", "one", "two"]])
458
+ await expectBuiltinLikeSystem("tail", args, "", directory);
459
+ } finally { rmSync(directory, { recursive: true, force: true }); }
460
+ });
461
+
462
+ test("wc prints each file count and a total; cut reads each file", async () => {
463
+ const directory = mkdtempSync(join(tmpdir(), "bunmsh-text-reference-"));
464
+ try {
465
+ await Bun.write(join(directory, "one"), "alpha beta\ngamma\n");
466
+ await Bun.write(join(directory, "two"), "delta\nepsilon zeta\n");
467
+ for (const args of [["-l", "one", "two"], ["-lwc", "one", "two"]])
468
+ await expectBuiltinLikeSystem("wc", args, "", directory);
469
+ await expectBuiltinLikeSystem("cut", ["-c", "1-3", "one", "two"], "", directory);
470
+ await expectBuiltinLikeSystem("cut", ["-c", "1", "one"], "", directory);
471
+ } finally { rmSync(directory, { recursive: true, force: true }); }
472
+ });
473
+
474
+ test("md5sum and sha256sum hash each file separately", async () => {
475
+ const directory = mkdtempSync(join(tmpdir(), "bunmsh-hash-reference-"));
476
+ try {
477
+ await Bun.write(join(directory, "one"), "alpha\n");
478
+ await Bun.write(join(directory, "two"), "beta\n");
479
+ for (const name of ["md5sum", "sha256sum"]) {
480
+ await expectBuiltinLikeSystem(name, ["one", "two"], "", directory);
481
+ await expectBuiltinLikeSystem(name, [], "stdin text\n", directory);
482
+ }
483
+ } finally { rmSync(directory, { recursive: true, force: true }); }
484
+ });
485
+ });
486
+
405
487
  describe("system tail reference", () => {
406
488
  test("supports -n +N and compact -n+N from-start modes", async () => {
407
489
  const input = "one\ntwo\nthree\nfour\n";
@@ -450,6 +532,18 @@ describe("system grep reference", () => {
450
532
  await expectBuiltinGrepLike(["-n", "needle", "tree/a.txt", "tree/nested/b.txt"], "", directory);
451
533
  } finally { rmSync(directory, { recursive: true, force: true }); }
452
534
  });
535
+
536
+ test("searches the current directory for -r without a path", async () => {
537
+ const directory = mkdtempSync(join(tmpdir(), "bunmsh-grep-cwd-reference-"));
538
+ try {
539
+ mkdirSync(join(directory, "nested"));
540
+ await Bun.write(join(directory, "nested", "a.txt"), "needle one\nnone\n");
541
+ await expectBuiltinGrepLike(["-r", "needle"], "", directory);
542
+ await expectBuiltinGrepLike(["-rn", "needle"], "", directory);
543
+ await expectBuiltinGrepLike(["-r", "missing"], "", directory);
544
+ await expectBuiltinGrepLike(["-rq", "needle"], "", directory);
545
+ } finally { rmSync(directory, { recursive: true, force: true }); }
546
+ });
453
547
  });
454
548
 
455
549
  describe("system sed reference", () => {