buninu-linux 0.17.60 → 0.18.62

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 (38) hide show
  1. package/NOTICE.md +46 -46
  2. package/README.md +4 -4
  3. package/README.zh.md +4 -4
  4. package/initramfs/bin/tar +32 -15
  5. package/initramfs/bin/unzip +74 -19
  6. package/initramfs/buninu/CHANGELOG.md +19 -0
  7. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +21 -0
  8. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  9. package/initramfs/buninu/apps/bunmsh/src/find.js +135 -56
  10. package/initramfs/buninu/apps/bunmsh/src/main.js +12 -1
  11. package/initramfs/buninu/apps/bunmsh/src/shell.js +8 -2
  12. package/initramfs/buninu/apps/bunmsh/test/find.test.js +103 -0
  13. package/initramfs/buninu/apps/bunmsh/test/interactive-typeahead.test.js +40 -0
  14. package/initramfs/buninu/apps/bunmsh/test/posix-bootstrap.test.js +9 -5
  15. package/initramfs/buninu/apps/bunmsh/test/reference.test.js +2 -5
  16. package/initramfs/buninu/apps/bunmsh/test/shell2.test.js +6 -5
  17. package/initramfs/buninu/apps/bunmsh/test/shell3.test.js +8 -7
  18. package/initramfs/buninu/apps/bunmsh/test/support/terminal.js +10 -0
  19. package/initramfs/buninu/apps/jsgotty/bunfig.toml +3 -0
  20. package/initramfs/buninu/apps/jsgotty/gotty.js +14 -1
  21. package/initramfs/buninu/apps/jsgotty/kitty-flow.js +40 -0
  22. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  23. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  24. package/initramfs/buninu/apps/jsgotty/static/js/kitty.js +32 -4
  25. package/initramfs/buninu/apps/jsgotty/tests/kitty-flow-browser.test.js +43 -0
  26. package/initramfs/buninu/apps/jsgotty/tests/kitty-flow.test.js +55 -0
  27. package/initramfs/buninu/apps/jsgotty/tests/manual/fps-collector.mjs +18 -0
  28. package/initramfs/buninu/apps/jsgotty/tests/manual/fps-debug.js +31 -0
  29. package/initramfs/buninu/apps/jsgotty/tests/manual/js-eval-show-fps.js +35 -0
  30. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-background-debug.js +24 -0
  31. package/initramfs/buninu/package.json +1 -1
  32. package/package.json +1 -1
  33. package/scripts/arch.js +5 -5
  34. package/scripts/arch.sh +5 -5
  35. package/scripts/fetch.js +50 -15
  36. package/scripts/npm-packages.md +11 -11
  37. package/test/archive-bounds.test.js +128 -0
  38. package/test/fetch-npm.test.js +1 -1
@@ -23,80 +23,165 @@ export function findIsRegularBuiltin(platform = process.platform) {
23
23
  return platform === "win32";
24
24
  }
25
25
 
26
- export async function runFind(argv, state, platform = process.platform, execute = null) {
26
+ // `write`, when given, takes each result as it is found, so the caller can stream it; without it
27
+ // the results are gathered and returned whole, as a captured `$(find ...)` needs.
28
+ export async function runFind(argv, state, platform = process.platform, execute = null, write = null) {
27
29
  let index = 1;
28
30
  const roots = [];
29
- while (index < argv.length && argv[index] !== "!" && argv[index] !== "-not" &&
31
+ while (index < argv.length && argv[index] !== "!" && argv[index] !== "-not" && argv[index] !== "(" && argv[index] !== ")" &&
30
32
  !argv[index].startsWith("-")) roots.push(argv[index++]);
31
33
  if (!roots.length) roots.push(".");
32
- let minDepth = 0, maxDepth = Infinity, print0 = false, negate = false, explicitPrint = false;
33
- const predicates = [];
34
- const actions = [];
35
- while (index < argv.length) {
34
+ let minDepth = 0, maxDepth = Infinity;
35
+ // The expression, parsed the POSIX way: `!` binds tightest, then -a (also implied between two
36
+ // terms), then -o; parentheses group. -prune keeps find out of a directory it matched.
37
+ let failure = null;
38
+ const fail = (message) => { failure ??= message; return { kind: "false" }; };
39
+ const peek = () => argv[index];
40
+ let hasAction = false;
41
+ const execs = [];
42
+ let nesting = 0;
43
+ const parsePrimary = () => {
44
+ if (++nesting > 128) throw new Error("expression nesting is too deep");
45
+ try { return parseTerm(); } finally { nesting--; }
46
+ };
47
+ const parseTerm = () => {
36
48
  const option = argv[index++];
37
- if (option === "!" || option === "-not") { negate = !negate; continue; }
38
- if (option === "-print") { explicitPrint = true; continue; }
39
- if (option === "-print0") { explicitPrint = true; print0 = true; continue; }
49
+ if (option === "(") {
50
+ const inner = parseOr();
51
+ if (argv[index++] !== ")") return fail("bunmsh: find: missing )\n");
52
+ return inner;
53
+ }
54
+ if (option === "!" || option === "-not") {
55
+ if (index >= argv.length || [")", "-o", "-or"].includes(peek())) return fail("bunmsh: find: expected expression after !\n");
56
+ return { kind: "not", node: parsePrimary() };
57
+ }
58
+ if (option === "-print" || option === "-print0") { hasAction = true; return { kind: "print", nul: option === "-print0" }; }
59
+ if (option === "-prune") return { kind: "prune" };
60
+ if (option === "-true") return { kind: "true" };
61
+ if (option === "-false") return { kind: "false" };
40
62
  if (option === "-exec") {
41
63
  const command = [];
42
- while (index < argv.length && argv[index] !== ";" && argv[index] !== "+")
64
+ while (index < argv.length && argv[index] !== ";" && !(argv[index] === "+" && command.at(-1)?.includes("{}")))
43
65
  command.push(argv[index++]);
44
66
  const terminator = argv[index++];
45
67
  if (!command.length || (terminator !== ";" && terminator !== "+"))
46
- return { status: 2, stdout: "", stderr: "bunmsh: find: -exec: missing command or terminator\n" };
47
- if (!command.includes("{}"))
48
- return { status: 2, stdout: "", stderr: "bunmsh: find: -exec: command must contain {}\n" };
49
- actions.push({ command, mode: terminator });
50
- continue;
68
+ return fail("bunmsh: find: -exec: missing command or terminator\n");
69
+ if (terminator === "+" && (command.at(-1) !== "{}" ||
70
+ command.filter(argument => argument.includes("{}")).length !== 1))
71
+ return fail("bunmsh: find: -exec + requires one {} as the final argument\n");
72
+ hasAction = true;
73
+ const node = { kind: "exec", command, mode: terminator, matches: [], matchBytes: 0 };
74
+ execs.push(node);
75
+ return node;
51
76
  }
52
77
  const argument = argv[index++];
53
- if (argument === undefined)
54
- return { status: 2, stdout: "", stderr: `bunmsh: find: ${option}: missing argument\n` };
55
- let predicate;
78
+ if (argument === undefined) return fail(`bunmsh: find: ${option}: missing argument\n`);
56
79
  if (option === "-name" || option === "-iname") {
57
80
  const regex = globRegex(argument, option === "-iname");
58
- predicate = (entry) => regex.test(basename(entry.shown));
59
- } else if (option === "-path" || option === "-ipath") {
81
+ return { kind: "test", test: (entry) => regex.test(basename(entry.shown)) };
82
+ }
83
+ if (option === "-path" || option === "-ipath") {
60
84
  const regex = globRegex(argument, option === "-ipath");
61
- predicate = (entry) => regex.test(entry.shown);
62
- } else if (option === "-type") {
63
- if (!/^[fdl]$/.test(argument))
64
- return { status: 2, stdout: "", stderr: `bunmsh: find: unsupported type: ${argument}\n` };
65
- predicate = (entry) => argument === "f" ? entry.stats.isFile() :
66
- argument === "d" ? entry.stats.isDirectory() : entry.stats.isSymbolicLink();
67
- } else if (option === "-mindepth" || option === "-maxdepth") {
68
- if (!/^\d+$/.test(argument))
69
- return { status: 2, stdout: "", stderr: `bunmsh: find: ${option}: invalid depth: ${argument}\n` };
85
+ return { kind: "test", test: (entry) => regex.test(entry.shown) };
86
+ }
87
+ if (option === "-type") {
88
+ if (!/^[fdl]$/.test(argument)) return fail(`bunmsh: find: unsupported type: ${argument}\n`);
89
+ return { kind: "test", test: (entry) => argument === "f" ? entry.stats.isFile() :
90
+ argument === "d" ? entry.stats.isDirectory() : entry.stats.isSymbolicLink() };
91
+ }
92
+ if (option === "-mindepth" || option === "-maxdepth") {
93
+ if (!/^\d+$/.test(argument)) return fail(`bunmsh: find: ${option}: invalid depth: ${argument}\n`);
70
94
  if (option === "-mindepth") minDepth = Number(argument); else maxDepth = Number(argument);
71
- continue;
72
- } else return { status: 2, stdout: "", stderr: `bunmsh: find: unsupported option: ${option}\n` };
73
- predicates.push(negate ? (entry) => !predicate(entry) : predicate);
74
- negate = false;
95
+ return { kind: "true" };
96
+ }
97
+ return fail(`bunmsh: find: unsupported option: ${option}\n`);
98
+ };
99
+ const parseAnd = () => {
100
+ const nodes = [parsePrimary()];
101
+ while (index < argv.length && ![")", "-o", "-or"].includes(peek())) {
102
+ if (peek() === "-a" || peek() === "-and") index++;
103
+ nodes.push(parsePrimary());
104
+ }
105
+ return { kind: "all", nodes };
106
+ };
107
+ function parseOr() {
108
+ const nodes = [parseAnd()];
109
+ while (peek() === "-o" || peek() === "-or") {
110
+ index++;
111
+ nodes.push(parseAnd());
112
+ }
113
+ return { kind: "any", nodes };
75
114
  }
76
- if (negate) return { status: 2, stdout: "", stderr: "bunmsh: find: expected expression after !\n" };
115
+ let expression;
116
+ try { expression = index < argv.length ? parseOr() : { kind: "true" }; }
117
+ catch (error) { return { status: 2, stdout: "", stderr: `bunmsh: find: ${error.message}\n` }; }
118
+ if (index < argv.length) fail(`bunmsh: find: unexpected ${argv[index]}\n`);
119
+ if (failure) return { status: 2, stdout: "", stderr: failure };
120
+ // With no action named, the whole expression is followed by -print.
121
+ if (!hasAction) expression = { kind: "and", left: expression, right: { kind: "print", nul: false } };
77
122
  let status = 0, stdout = "", stderr = "";
78
123
  const decoder = new TextDecoder();
79
- const appendExecution = (execution) => {
80
- stdout += typeof execution.stdout === "string" ? execution.stdout : decoder.decode(execution.stdout);
124
+ const appendExecution = async (execution) => {
125
+ const text = typeof execution.stdout === "string" ? execution.stdout : decoder.decode(execution.stdout);
126
+ if (write && text) await write(text); else stdout += text;
81
127
  stderr += typeof execution.stderr === "string" ? execution.stderr : decoder.decode(execution.stderr);
82
128
  };
83
- const matches = [];
84
- const emit = (shown) => { stdout += shown + (print0 ? "\0" : "\n"); };
129
+ const emit = async (shown, nul) => {
130
+ const line = shown + (nul ? "\0" : "\n");
131
+ if (write) await write(line); else stdout += line;
132
+ };
133
+ // Keep path batches small enough for common exec argument limits, and release
134
+ // them during traversal instead of retaining the whole directory tree.
135
+ const flush = async node => {
136
+ if (!node.matches.length) return;
137
+ const matches = node.matches;
138
+ node.matches = [];
139
+ node.matchBytes = 0;
140
+ if (!execute) { status = 1; return; }
141
+ const execution = await execute([...node.command.slice(0, -1), ...matches]);
142
+ await appendExecution(execution);
143
+ if (execution.status !== 0) status = 1;
144
+ };
145
+ const evaluate = async (node, entry) => {
146
+ switch (node.kind) {
147
+ case "true": return true;
148
+ case "false": return false;
149
+ case "test": return node.test(entry);
150
+ case "all":
151
+ for (const child of node.nodes) if (!(await evaluate(child, entry))) return false;
152
+ return true;
153
+ case "any":
154
+ for (const child of node.nodes) if (await evaluate(child, entry)) return true;
155
+ return false;
156
+ case "not": return !(await evaluate(node.node, entry));
157
+ case "and": return (await evaluate(node.left, entry)) && evaluate(node.right, entry);
158
+ case "or": return (await evaluate(node.left, entry)) || evaluate(node.right, entry);
159
+ case "prune": entry.prune = true; return true;
160
+ case "print": await emit(entry.shown, node.nul); return true;
161
+ case "exec":
162
+ if (node.mode === "+") {
163
+ const size = Buffer.byteLength(entry.shown) + 9;
164
+ if (node.matchBytes + size > 32768) await flush(node);
165
+ node.matches.push(entry.shown);
166
+ node.matchBytes += size;
167
+ return true;
168
+ }
169
+ if (!execute) return false;
170
+ {
171
+ const execution = await execute(node.command.map((argument) => argument.replaceAll("{}", entry.shown)));
172
+ await appendExecution(execution);
173
+ return execution.status === 0;
174
+ }
175
+ }
176
+ return false;
177
+ };
85
178
  const visit = async (native, shown, depth) => {
86
179
  let stats;
87
180
  try { stats = lstatSync(native); }
88
181
  catch (error) { status = 1; stderr += `bunmsh: find: ${shown}: ${error.message}\n`; return; }
89
- const entry = { shown, stats };
90
- if (depth >= minDepth && predicates.every((predicate) => predicate(entry))) {
91
- matches.push(shown);
92
- if (!actions.length || explicitPrint) emit(shown);
93
- for (const action of actions) if (action.mode === ";") {
94
- if (!execute) return;
95
- const command = action.command.map((argument) => argument === "{}" ? shown : argument);
96
- appendExecution(await execute(command));
97
- }
98
- }
99
- if (!stats.isDirectory() || depth >= maxDepth) return;
182
+ const entry = { shown, stats, prune: false };
183
+ if (depth >= minDepth) await evaluate(expression, entry);
184
+ if (entry.prune || !stats.isDirectory() || depth >= maxDepth) return;
100
185
  let children;
101
186
  try { children = readdirSync(native).sort(); }
102
187
  catch (error) { status = 1; stderr += `bunmsh: find: ${shown}: ${error.message}\n`; return; }
@@ -108,12 +193,6 @@ export async function runFind(argv, state, platform = process.platform, execute
108
193
  ? nativePath(root, platform) : resolve(nativePath(state.cwd, platform), nativePath(root, platform));
109
194
  await visit(native, shellPath(root), 0);
110
195
  }
111
- for (const action of actions) if (action.mode === "+" && matches.length) {
112
- if (!execute) continue;
113
- const command = action.command.flatMap((argument) => argument === "{}" ? matches : [argument]);
114
- const execution = await execute(command);
115
- appendExecution(execution);
116
- if (execution.status !== 0) status = 1;
117
- }
196
+ for (const node of execs) if (node.mode === "+") await flush(node);
118
197
  return { status, stdout, stderr };
119
198
  }
@@ -228,6 +228,10 @@ async function interactive(state) {
228
228
  let savedHead = "";
229
229
  let savedTail = "";
230
230
  let promptAbort = null;
231
+ // question() consumes one line only. Resuming a paused terminal can deliver
232
+ // several buffered lines before the next question is installed; readline
233
+ // emits the remaining lines as events. Retain them in submission order.
234
+ const queuedLines = [];
231
235
  // Set by onPaste (below) when a bracketed paste contains a newline, and
232
236
  // consumed by the main loop's readline.question() catch block right after
233
237
  // promptAbort.abort() rejects it -- see the comment there.
@@ -447,6 +451,7 @@ async function interactive(state) {
447
451
  }
448
452
  state.lastStatus = 130;
449
453
  if (foregroundCommand || promptAbort?.signal.aborted) return;
454
+ queuedLines.length = 0;
450
455
  process.stdout.write("\n");
451
456
  promptAbort?.abort();
452
457
  };
@@ -500,6 +505,7 @@ async function interactive(state) {
500
505
  (listener) => !resizeListenersBeforeReadline.has(listener))
501
506
  : [];
502
507
  readline.on("SIGINT", interrupt);
508
+ readline.on("line", line => queuedLines.push(line));
503
509
  closed = new Promise((resolve) => readline.once("close", () => resolve(null)));
504
510
  };
505
511
  setupReadline();
@@ -629,7 +635,12 @@ async function interactive(state) {
629
635
  promptAbort = new AbortController();
630
636
  let line;
631
637
  try {
632
- line = await Promise.race([
638
+ if (queuedLines.length) {
639
+ // Already echoed by readline when submitted; do not paint a prompt
640
+ // in front of the queued command's output.
641
+ if (!terminal) process.stdout.write(rendered.text);
642
+ line = queuedLines.shift();
643
+ } else line = await Promise.race([
633
644
  readline.question(rendered.text, { signal: promptAbort.signal }),
634
645
  closed,
635
646
  ]);
@@ -1920,9 +1920,15 @@ const builtins = {
1920
1920
  },
1921
1921
  };
1922
1922
 
1923
- const runFindBuiltin = async (argv, state) => {
1923
+ // Stream only an uncaptured stdout endpoint. Pipeline children can themselves
1924
+ // capture output (for example in command substitutions), so their flag is not enough.
1925
+ const runFindBuiltin = async (argv, state, input, context = {}) => {
1926
+ const endpoint = state.fdBindings?.[1] ?? { channel: 1 };
1927
+ const streamStdout = !context.captureStdout && !context.options?.stdoutSink &&
1928
+ endpoint.channel === 1 && endpoint.fd === undefined && !endpoint.closed;
1924
1929
  const output = await runFind(argv, state, process.platform, (command) =>
1925
- runCommandArgv(command, state, null, true, true));
1930
+ runCommandArgv(command, state, null, true, true),
1931
+ streamStdout ? (text) => writeStream(process.stdout, bytes(text)) : null);
1926
1932
  return result(output.status, output.stdout, output.stderr);
1927
1933
  };
1928
1934
 
@@ -0,0 +1,103 @@
1
+ import {test, expect} from "bun:test";
2
+ import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from "node:fs";
3
+ import {tmpdir} from "node:os";
4
+ import {join} from "node:path";
5
+ import {runFind} from "../src/find.js";
6
+ import {createState, execute, decode} from "../src/shell.js";
7
+
8
+ async function fixture(fn) {
9
+ const cwd = mkdtempSync(join(tmpdir(), "bunmsh-find-expression-"));
10
+ mkdirSync(join(cwd, "skip"));
11
+ for (const file of ["a.txt", "b.js", "skip/hidden.txt"]) writeFileSync(join(cwd, file), "");
12
+ try { await fn(cwd); } finally { rmSync(cwd, {recursive:true, force:true}); }
13
+ }
14
+ test("find groups expressions, short circuits actions, and prunes traversal", () => fixture(async cwd => {
15
+ const run = args => runFind(["find", ".", ...args], {cwd});
16
+ expect((await run(["-name","skip","-prune","-o","-type","f","-print"])).stdout).toBe("./a.txt\n./b.js\n");
17
+ expect((await run(["(","-name","*.txt","-o","-name","*.js",")","!","-path","./skip/*"])).stdout).toBe("./a.txt\n./b.js\n");
18
+ expect((await run(["-false","-a","-print","-o","-maxdepth","0","-print0"])).stdout).toBe(".\0");
19
+ }));
20
+ test("find exec supports constant commands, embedded placeholders and literal plus", () => fixture(async cwd => {
21
+ const calls = [];
22
+ const exec = async args => { calls.push(args); return {status:1,stdout:"",stderr:""}; };
23
+ const run = args => runFind(["find",".","-maxdepth","0",...args],{cwd},process.platform,exec);
24
+ expect((await run(["-exec","echo","constant",";","-o","-print"])).stdout).toBe(".\n");
25
+ expect(calls.pop()).toEqual(["echo","constant"]);
26
+ await run(["-exec","echo","prefix{}suffix","literal","+",";"]);
27
+ expect(calls.pop()).toEqual(["echo","prefix.suffix","literal","+"]);
28
+ expect((await run(["-exec","echo","{}","{}","+"])).status).toBe(2);
29
+ expect(calls).toHaveLength(0);
30
+ expect((await run(["-exec","echo","{}","+"])).status).toBe(1);
31
+ expect(calls.pop()).toEqual(["echo","."]);
32
+ }));
33
+ test("find rejects invalid expressions before any action", () => fixture(async cwd => {
34
+ for (const args of [["-print","-o"],["(","-print"],["!"],["-name","[z-a]"]]) {
35
+ const chunks=[];
36
+ const out=await runFind(["find",".",...args],{cwd},process.platform,null,text=>chunks.push(text));
37
+ expect(out.status).toBe(2);
38
+ expect(chunks).toEqual([]);
39
+ }
40
+ }));
41
+ test("find awaits streamed output and leaves captured stdout empty", () => fixture(async cwd => {
42
+ const args=["find",".","-type","f","-print0"];
43
+ const expected=await runFind(args,{cwd});
44
+ const chunks=[];
45
+ const streamed=await runFind(args,{cwd},process.platform,null,async text=>{
46
+ await Bun.sleep(1); chunks.push(text);
47
+ });
48
+ expect(streamed).toEqual({status:0,stdout:"",stderr:""});
49
+ expect(chunks.join("")).toBe(expected.stdout);
50
+ }));
51
+ test("find capture remains isolated inside a pipeline child", () => fixture(async cwd => {
52
+ const state=createState({cwd,env:{...process.env},args:["bunmsh"]});
53
+ state.pipelineChild=true;
54
+ const out=await execute('printf "<%s>\\n" "$(builtin find . -maxdepth 0)"',state,{capture:true});
55
+ expect(decode(out.stdout)).toBe("<.>\n");
56
+ expect(out.status).toBe(0);
57
+ }));
58
+ test("find streams through pipes and obeys output redirection", () => fixture(async cwd => {
59
+ const state=createState({cwd,env:{...process.env},args:["bunmsh"]});
60
+ for (const source of [
61
+ "builtin find . -maxdepth 0 | cat",
62
+ "builtin find . -maxdepth 0 > out; cat out",
63
+ "builtin find . -maxdepth 0 1>&2",
64
+ ]) {
65
+ const out=await execute(source,state,{capture:true});
66
+ expect(out.status).toBe(0);
67
+ expect(decode(source.includes("1>&2")?out.stderr:out.stdout)).toBe(".\n");
68
+ }
69
+ }));
70
+ test("find handles long flat expressions and rejects excessive nesting", () => fixture(async cwd => {
71
+ const flat=await runFind(["find",".","-maxdepth","0",...Array(10000).fill("-true")],{cwd});
72
+ expect(flat).toEqual({status:0,stdout:".\n",stderr:""});
73
+ const nested=await runFind(["find",".",...Array(1000).fill("!"),"-true"],{cwd});
74
+ expect(nested.status).toBe(2);
75
+ }));
76
+ test("find batches exec paths without losing order", () => fixture(async cwd => {
77
+ const paths=Array(5000).fill(".");
78
+ const calls=[];
79
+ const out=await runFind(["find",...paths,"-maxdepth","0","-exec","echo","{}","+"],{cwd},process.platform,
80
+ async args=>{ calls.push(args.slice(1)); return {status:0,stdout:"",stderr:""}; });
81
+ expect(out.status).toBe(0);
82
+ expect(calls.length).toBeGreaterThan(1);
83
+ expect(calls.flat()).toEqual(paths);
84
+ }));
85
+ test("find expressions agree with the host find on the same tree", () => fixture(async cwd => {
86
+ const reference=Bun.which("find");
87
+ if (!reference) throw new Error("reference find is required for this comparison");
88
+ for (const args of [
89
+ ["-name","skip","-prune","-o","-type","f","-print"],
90
+ ["(","-name","*.txt","-o","-name","*.js",")","!","-path","./skip/*"],
91
+ ["-false","-a","-print","-o","-maxdepth","0","-print0"],
92
+ ["-maxdepth","0","-exec","echo","constant",";"],
93
+ ["-maxdepth","0","-exec","echo","prefix{}suffix","literal","+",";"],
94
+ ]) {
95
+ const expected=Bun.spawnSync([reference,".",...args],{cwd});
96
+ const actual=await runFind(["find",".",...args],{cwd},process.platform,async command=>{
97
+ const p=Bun.spawnSync(command,{cwd});
98
+ return {status:p.exitCode,stdout:p.stdout,stderr:p.stderr};
99
+ });
100
+ expect({args, status:actual.status}).toEqual({args,status:expected.exitCode});
101
+ expect(actual.stdout.split("\n").sort()).toEqual(expected.stdout.toString().split("\n").sort());
102
+ }
103
+ }));
@@ -0,0 +1,40 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {mkdtempSync, rmSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+
6
+ for (const delay of [0, 150, null]) test(`retains multiple submitted lines during cursor query (${delay ?? 'no'} reply)`, async () => {
7
+ const home = mkdtempSync(join(tmpdir(), 'bunmsh-typeahead-'));
8
+ const timers = [];
9
+ let text = '', started = false, queried = false;
10
+ const later = (fn, ms) => timers.push(setTimeout(fn, ms));
11
+ const terminal = new Bun.Terminal({cols:80, rows:24, data(t, data) {
12
+ const chunk = data.toString(); text += chunk;
13
+ if (!started && text.includes('READY> ')) {
14
+ started = true;
15
+ later(() => t.write('printf "FIRST\\n"\r'), 0);
16
+ }
17
+ if (chunk.includes('\x1b[6n')) {
18
+ if (!queried) {
19
+ queried = true;
20
+ // One PTY chunk: readline must not discard the second completed line.
21
+ later(() => t.write('printf "SECOND\\n"\rprintf "THIRD\\n"\rexit\r'), 30);
22
+ }
23
+ if (delay !== null) later(() => t.write('\x1b[1;1R'), delay);
24
+ }
25
+ }});
26
+ const proc = Bun.spawn([process.execPath, join(import.meta.dir, '../src/main.js'), '-i'], {
27
+ env:{...process.env, HOME:home, BUNMSH_IMPORT_HISTORY:'off', PS1:'READY> ', BUNMSH_MOUSE:'0'}, terminal,
28
+ });
29
+ const guard = setTimeout(() => proc.kill(), 5000);
30
+ try {
31
+ expect(await proc.exited).toBe(0);
32
+ const lines = Bun.stripANSI(text).replaceAll('\r', '').split('\n');
33
+ for (const marker of ['FIRST','SECOND','THIRD']) expect(lines.filter(line => line === marker)).toHaveLength(1);
34
+ expect(lines.indexOf('SECOND')).toBeLessThan(lines.indexOf('THIRD'));
35
+ } finally {
36
+ clearTimeout(guard); for (const timer of timers) clearTimeout(timer);
37
+ if (proc.exitCode === null) {proc.kill(); await proc.exited;}
38
+ terminal.close(); rmSync(home, {recursive:true, force:true});
39
+ }
40
+ }, 10000);
@@ -72,9 +72,13 @@ test("redirect order snapshots the original stdout and truncates null commands",
72
72
  const dir = mkdtempSync(join(tmpdir(),'bunmsh-io-order-'));
73
73
  try {
74
74
  const path = join(dir,'out');
75
- const cmd = `'${process.execPath}' -e 'console.log("out");console.error("err")'`;
75
+ // Test descriptor routing, not console formatting: FORCE_COLOR can make
76
+ // console.error emit ANSI codes even when its output is captured in a pipe.
77
+ const cmd = `'${process.execPath}' -e 'const {writeSync}=require("node:fs");writeSync(1,"out\\n");writeSync(2,"err\\n")'`;
76
78
  let r = await run(`${cmd} 2>&1 >'${path}'`);
77
- expect(r.stdout).toBe('err\n'); expect(r.stderr).toBe('');
79
+ // Escape control characters in failures: terminal-rendered string diffs
80
+ // can hide CR, ANSI sequences, or NUL bytes behind an apparent newline.
81
+ expect(JSON.stringify(r.stdout)).toBe(JSON.stringify('err\n')); expect(r.stderr).toBe('');
78
82
  expect(readFileSync(path,'utf8')).toBe('out\n');
79
83
  r = await run(`${cmd} >'${path}' 2>&1`);
80
84
  expect(r.stdout).toBe(''); expect(r.stderr).toBe('');
@@ -219,7 +223,7 @@ test("only posix mode removes ls icons and survives pipeline state transfer", as
219
223
  const dir=mkdtempSync(join(tmpdir(),'bunmsh-posix-ls-'));
220
224
  try {
221
225
  const path=join(dir,'file'); writeFileSync(path,'x');
222
- const r=await run(`ls '${path}'\nset -o posix\nls '${path}' | cat\nlsfancy '${path}'\nset +o posix\nls '${path}'`);
226
+ const r=await run(`builtin ls '${path}'\nset -o posix\nbuiltin ls '${path}' | cat\nlsfancy '${path}'\nset +o posix\nbuiltin ls '${path}'`);
223
227
  expect(r.stdout).toBe(`📄 ${path}\n${path}\n📄 ${path}\n📄 ${path}\n`); expect(r.stderr).toBe('');
224
228
  const options=await run('set -o\nset -o posix\necho "flags=$-"\nset +o');
225
229
  expect(options.stdout).toContain('posix off\n'); expect(options.stdout).toContain('flags=\n'); expect(options.stdout).toContain('set -o posix\n');
@@ -508,10 +512,10 @@ test("uniq supports output files and rejects unsupported options", async () => {
508
512
  const dir = mkdtempSync(join(tmpdir(),'bunmsh-uniq-'));
509
513
  try {
510
514
  writeFileSync(join(dir,'input'),'a\na\nb');
511
- const r=await run(`uniq '${dir}/input' '${dir}/output'`);
515
+ const r=await run(`builtin uniq '${dir}/input' '${dir}/output'`);
512
516
  expect(r.status).toBe(0); expect(r.stdout).toBe('');
513
517
  expect(readFileSync(join(dir,'output'),'utf8')).toBe('a\nb\n');
514
- expect((await run('uniq -z')).status).toBe(1);
518
+ expect((await run('builtin uniq -z')).status).toBe(1);
515
519
  } finally { rmSync(dir,{recursive:true,force:true}); }
516
520
  });
517
521
  test("rm force ignores empty filenames while still deleting real operands", async () => {
@@ -417,19 +417,18 @@ describe("system multi-operand reference", () => {
417
417
  await expectBuiltinLikeSystem("printf", ["[%s][%s]\n", "one", "two", "three"]);
418
418
  });
419
419
 
420
- test("ln links each source into a target directory", async () => {
420
+ test("ln symlinks each source into a target directory", async () => {
421
421
  const directory = mkdtempSync(join(tmpdir(), "bunmsh-ln-reference-"));
422
422
  const actualDir = join(directory, "actual");
423
423
  const systemDir = join(directory, "system");
424
424
  try {
425
425
  for (const cwd of [actualDir, systemDir]) {
426
426
  mkdirSync(cwd);
427
- mkdirSync(join(cwd, "hard"));
428
427
  mkdirSync(join(cwd, "soft"));
429
428
  await Bun.write(join(cwd, "one"), "first\n");
430
429
  await Bun.write(join(cwd, "two"), "second\n");
431
430
  }
432
- for (const args of [["one", "two", "hard"], ["-s", "one", "two", "soft"]]) {
431
+ for (const args of [["-s", "one", "two", "soft"]]) {
433
432
  const [actual, reference] = await Promise.all([
434
433
  invokeArgvWithInput([process.execPath, join(root, "src/main.js"), "-cc", "builtin", "ln", ...args], "", actualDir),
435
434
  invokeArgvWithInput([Bun.which("ln"), ...args], "", systemDir),
@@ -437,8 +436,6 @@ describe("system multi-operand reference", () => {
437
436
  expect(actual).toEqual(reference);
438
437
  }
439
438
  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
439
  expect(readlinkSync(join(actualDir, "soft", name)))
443
440
  .toBe(readlinkSync(join(systemDir, "soft", name)));
444
441
  }
@@ -1,3 +1,4 @@
1
+ import { answerCursorQueries } from "./support/terminal.js";
1
2
  import { describe, expect, test } from "bun:test";
2
3
  import { chmodSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
@@ -156,7 +157,7 @@ describe("CLI", () => {
156
157
  const terminal = new Bun.Terminal({
157
158
  cols: 80,
158
159
  rows: 24,
159
- data(_terminal, data) { transcript += data.toString(); },
160
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
160
161
  });
161
162
  const proc = Bun.spawn({
162
163
  cmd: [process.execPath, "src/main.js", "-i"],
@@ -182,7 +183,7 @@ describe("CLI", () => {
182
183
  const terminal = new Bun.Terminal({
183
184
  cols: 80,
184
185
  rows: 24,
185
- data(_terminal, data) { transcript += data.toString(); },
186
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
186
187
  });
187
188
  const proc = Bun.spawn({
188
189
  cmd: [process.execPath, "src/main.js", "-i"],
@@ -218,7 +219,7 @@ describe("CLI", () => {
218
219
  const terminal = new Bun.Terminal({
219
220
  cols: 80,
220
221
  rows: 24,
221
- data(_terminal, data) { transcript += data.toString(); },
222
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
222
223
  });
223
224
  const proc = Bun.spawn({
224
225
  cmd: [process.execPath, "src/main.js", "-i"],
@@ -249,7 +250,7 @@ describe("CLI", () => {
249
250
  const terminal = new Bun.Terminal({
250
251
  cols: 80,
251
252
  rows: 24,
252
- data(_terminal, data) { transcript += data.toString(); },
253
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
253
254
  });
254
255
  const proc = Bun.spawn({
255
256
  cmd: [process.execPath, "src/main.js", "-i"],
@@ -283,7 +284,7 @@ describe("CLI", () => {
283
284
  const terminal = new Bun.Terminal({
284
285
  cols: 80,
285
286
  rows: 24,
286
- data(_terminal, data) { transcript += data.toString(); },
287
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
287
288
  });
288
289
  const proc = Bun.spawn({
289
290
  cmd: [process.execPath, "src/main.js", "-i"],
@@ -1,3 +1,4 @@
1
+ import { answerCursorQueries } from "./support/terminal.js";
1
2
  import { describe, expect, test } from "bun:test";
2
3
  import { chmodSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
@@ -130,7 +131,7 @@ describe("CLI", () => {
130
131
  const terminal = new Bun.Terminal({
131
132
  cols: 100,
132
133
  rows: 30,
133
- data(_terminal, data) { transcript += data.toString(); },
134
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
134
135
  });
135
136
  try {
136
137
  const historyPath = join(home, ".local", "share", "bunmsh", "history");
@@ -164,7 +165,7 @@ describe("CLI", () => {
164
165
  const terminal = new Bun.Terminal({
165
166
  cols: 80,
166
167
  rows: 24,
167
- data(_terminal, data) { transcript += data.toString(); },
168
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
168
169
  });
169
170
  const proc = Bun.spawn({
170
171
  cmd: [Bun.which("bun") || process.argv0, "src/main.js", "-i"],
@@ -186,7 +187,7 @@ describe("CLI", () => {
186
187
  const terminal = new Bun.Terminal({
187
188
  cols: 80,
188
189
  rows: 24,
189
- data(_terminal, data) { transcript += data.toString(); },
190
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
190
191
  });
191
192
  const env = { ...process.env, PS1: "> " };
192
193
  delete env.BUNMSH_MOUSE;
@@ -228,7 +229,7 @@ describe("CLI", () => {
228
229
  const terminal = new Bun.Terminal({
229
230
  cols: 80,
230
231
  rows: 24,
231
- data(_terminal, data) { transcript += data.toString(); },
232
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
232
233
  });
233
234
  const env = { ...process.env, PS1: "> " };
234
235
  delete env.BUNMSH_MOUSE;
@@ -256,7 +257,7 @@ describe("CLI", () => {
256
257
  const terminal = new Bun.Terminal({
257
258
  cols: 100,
258
259
  rows: 30,
259
- data(_terminal, data) { transcript += data.toString(); },
260
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
260
261
  });
261
262
  const env = { ...process.env };
262
263
  delete env.PS1;
@@ -363,7 +364,7 @@ describe("CLI", () => {
363
364
  const terminal = new Bun.Terminal({
364
365
  cols: 100,
365
366
  rows: 30,
366
- data(_terminal, data) { transcript += data.toString(); },
367
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
367
368
  });
368
369
  const proc = Bun.spawn({
369
370
  cmd: [Bun.which("bun") || process.argv0, "src/main.js", "-i"],
@@ -400,7 +401,7 @@ describe("CLI", () => {
400
401
  const terminal = new Bun.Terminal({
401
402
  cols: 100,
402
403
  rows: 30,
403
- data(_terminal, data) { transcript += data.toString(); },
404
+ data(_terminal, data) { transcript += data.toString(); answerCursorQueries(_terminal, data); },
404
405
  });
405
406
  const proc = Bun.spawn({
406
407
  cmd: [Bun.which("bun") || process.argv0, "src/main.js", "-i"],
@@ -0,0 +1,10 @@
1
+ // Bun.Terminal is a PTY, not an emulator. Interactive tests must answer DSR
2
+ // instead of sending subsequent commands during the shell's fallback timeout.
3
+ const pending = new WeakMap();
4
+ export function answerCursorQueries(terminal, data) {
5
+ const text = (pending.get(terminal) ?? '') + data.toString();
6
+ for (const _ of text.matchAll(/\x1b\[6n/g)) terminal.write('\x1b[1;1R');
7
+ const escape = text.lastIndexOf('\x1b');
8
+ const tail = escape < 0 ? '' : text.slice(escape);
9
+ pending.set(terminal, tail.length < 4 && '\x1b[6n'.startsWith(tail) ? tail : '');
10
+ }
@@ -0,0 +1,3 @@
1
+ [test]
2
+ # Test jsgotty, not the vendored node-pty development suites.
3
+ root = "./tests"