buninu 0.4.0 → 0.4.2

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.
@@ -8,21 +8,35 @@ import {
8
8
  createState,
9
9
  decode,
10
10
  execute,
11
+ colorProcessCommand,
12
+ colorProcessTable,
13
+ highlightShellCommand,
11
14
  executeArgv,
15
+ formatProcessTable,
16
+ isJavaScriptMode,
12
17
  needsMoreInput,
13
18
  parse,
19
+ parsePosixProcessList,
20
+ parseWindowsProcessList,
14
21
  runUnameFallback,
22
+ SH_COLORS,
23
+ taskkillFailure,
15
24
  tokenize,
25
+ windowsKillCommand,
16
26
  } from "../src/shell.js";
17
27
  import {
18
28
  CommandIndex,
19
29
  FileIndex,
30
+ VariableIndex,
20
31
  completionContext,
21
32
  fitGhost,
22
33
  firstPrefixMatch,
23
34
  historyGhost,
35
+ javascriptContext,
24
36
  nextGhostChunk,
25
37
  prefixMatches,
38
+ variableCompletion,
39
+ variableContext,
26
40
  } from "../src/completion.js";
27
41
  import {
28
42
  bunmshHistoryPath,
@@ -373,7 +387,7 @@ describe("execution", () => {
373
387
  const files = { ":": "colon", ...titles };
374
388
  const optionDocs = new Set([
375
389
  "basename", "builtin", "bun", "bunmsh", "cat", "catfancy", "chmod", "command", "cp", "cut",
376
- "date", "dirname", "echo", "env", "find", "getopts", "grep", "head", "kill",
390
+ "curl", "date", "dirname", "echo", "env", "find", "getopts", "grep", "head", "kill",
377
391
  "ln", "ls", "lsbun", "lsfancy", "mkdir", "mktemp", "mv", "print", "printf",
378
392
  "read", "readonly", "rm", "rmdir", "sed", "seq", "serve", "set", "sleep",
379
393
  "sort", "tab", "tac", "tail", "tee", "test", "touch", "tr", "unalias",
@@ -772,9 +786,10 @@ fi
772
786
 
773
787
  test("provides color aliases for common commands", () => {
774
788
  const state = createState();
789
+ // toybox diff takes a bare --color; it has no =auto form.
775
790
  expect(state.aliases).toEqual({
776
791
  ls: ["ls", "--color=auto"],
777
- diff: ["diff", "--color=auto"],
792
+ diff: ["diff", "--color"],
778
793
  grep: ["grep", "--color=auto"],
779
794
  });
780
795
  });
@@ -1776,3 +1791,302 @@ describe("CLI", () => {
1776
1791
  expect(transcript).not.toContain(MOUSE_ON);
1777
1792
  });
1778
1793
  });
1794
+
1795
+ describe("pspa", () => {
1796
+ test("lists every PID with its full command line", async () => {
1797
+ const execution = await executeArgv(["builtin", "pspa"], createState(), { capture: true });
1798
+ expect(execution.status).toBe(0);
1799
+ const lines = decode(execution.stdout).split("\n");
1800
+ // POSIX passes `ps -eo pid,args` through untouched, so its header is the
1801
+ // header, and this test process has to be in the listing under itself.
1802
+ expect(lines[0]).toMatch(/^\s*PID COMMAND$/);
1803
+ const own = lines.find((line) => new RegExp(`^\\s*${process.pid} `).test(line));
1804
+ expect(own).toBeDefined();
1805
+ expect(own.length).toBeGreaterThan(String(process.pid).length + 1);
1806
+ });
1807
+
1808
+ test("takes no operands", async () => {
1809
+ const execution = await executeArgv(["builtin", "pspa", "extra"], createState(), { capture: true });
1810
+ expect(execution.status).toBe(2);
1811
+ expect(decode(execution.stderr)).toBe("bunmsh: pspa: unexpected operand: extra\n");
1812
+ });
1813
+
1814
+ test("parses the Windows Win32_Process listing into sorted rows", () => {
1815
+ // What the PowerShell one-liner writes: PID, a space, then the command
1816
+ // line — or the image name when a system process has none.
1817
+ const rows = parseWindowsProcessList(
1818
+ "4 System\r\n" +
1819
+ "9876 \"C:\\Program Files\\App\\app.exe\" --flag \"a b\"\r\n" +
1820
+ "1200 C:\\Windows\\system32\\svchost.exe -k netsvcs\r\n" +
1821
+ "\r\n",
1822
+ );
1823
+ expect(rows).toEqual([
1824
+ { pid: 4, args: "System" },
1825
+ { pid: 1200, args: "C:\\Windows\\system32\\svchost.exe -k netsvcs" },
1826
+ { pid: 9876, args: '"C:\\Program Files\\App\\app.exe" --flag "a b"' },
1827
+ ]);
1828
+ });
1829
+
1830
+ test("drops the continuation lines a multi-line command line produces", () => {
1831
+ expect(parseWindowsProcessList("42 one\nwrapped continuation\n88 two\n")).toEqual([
1832
+ { pid: 42, args: "one" },
1833
+ { pid: 88, args: "two" },
1834
+ ]);
1835
+ });
1836
+
1837
+ test("formats the Windows rows into the same two columns ps prints", () => {
1838
+ expect(formatProcessTable([{ pid: 4, args: "System" }, { pid: 1200, args: "svchost.exe" }]))
1839
+ .toBe(" PID COMMAND\n 4 System\n 1200 svchost.exe\n");
1840
+ // The column widens past procps' five columns for a longer PID.
1841
+ expect(formatProcessTable([{ pid: 123456, args: "app.exe" }]))
1842
+ .toBe(" PID COMMAND\n123456 app.exe\n");
1843
+ expect(formatProcessTable([])).toBe(" PID COMMAND\n");
1844
+ });
1845
+ });
1846
+
1847
+ describe("kill", () => {
1848
+ test("probes with signal 0 and reports an unknown pid", async () => {
1849
+ expect(await executeArgv(["builtin", "kill", "-0", String(process.pid)], createState(), { capture: true }))
1850
+ .toMatchObject({ status: 0 });
1851
+ const missing = await executeArgv(["builtin", "kill", "-0", "999999"], createState(), { capture: true });
1852
+ expect(missing.status).toBe(1);
1853
+ expect(decode(missing.stderr)).toContain("bunmsh: kill: 999999:");
1854
+ expect(await executeArgv(["builtin", "kill"], createState(), { capture: true }))
1855
+ .toMatchObject({ status: 2 });
1856
+ });
1857
+
1858
+ test.skipIf(process.platform === "win32")("signals a real process", async () => {
1859
+ const proc = Bun.spawn({ cmd: ["sleep", "30"], stdin: "ignore", stdout: "ignore", stderr: "ignore" });
1860
+ try {
1861
+ expect(await executeArgv(["builtin", "kill", String(proc.pid)], createState(), { capture: true }))
1862
+ .toMatchObject({ status: 0 });
1863
+ await proc.exited;
1864
+ expect(proc.signalCode).toBe("SIGTERM");
1865
+ } finally { proc.kill("SIGKILL"); }
1866
+ });
1867
+
1868
+ test("builds the taskkill command a Windows kill goes through", () => {
1869
+ // /T reaches the children libuv's TerminateProcess leaves running, /F is
1870
+ // what makes it work on a console process with no message loop.
1871
+ expect(windowsKillCommand(1234)).toEqual(["taskkill", "/PID", "1234", "/T", "/F"]);
1872
+ expect(windowsKillCommand("1234")).toEqual(["taskkill", "/PID", "1234", "/T", "/F"]);
1873
+ });
1874
+
1875
+ test("rewrites a taskkill failure as one of our own lines", () => {
1876
+ expect(taskkillFailure(0, "SUCCESS: The process with PID 1234 has been terminated.\r\n", ""))
1877
+ .toBeNull();
1878
+ expect(taskkillFailure(128, "", 'ERROR: The process "1234" not found.\r\n'))
1879
+ .toBe('The process "1234" not found.');
1880
+ expect(taskkillFailure(1, "ERROR: Access is denied.\r\n", "")).toBe("Access is denied.");
1881
+ expect(taskkillFailure(1, "", "")).toBe("taskkill exited with status 1");
1882
+ });
1883
+ });
1884
+
1885
+ describe("pspac", () => {
1886
+ const wrap = (name, value) => `${SH_COLORS[name]}${value}${SH_COLORS.reset}`;
1887
+
1888
+ test("reads the rows back out of the plain ps listing", () => {
1889
+ expect(parsePosixProcessList(" PID COMMAND\n 1 /init\n12771 bash -l\n\n")).toEqual([
1890
+ { pid: 1, args: "/init" },
1891
+ { pid: 12771, args: "bash -l" },
1892
+ ]);
1893
+ });
1894
+
1895
+ test("dims the directory and names the program the first token really is", () => {
1896
+ const line = colorProcessCommand("/usr/bin/bash --login -l notes.txt");
1897
+ // Colour is the only difference: the text itself is untouched.
1898
+ expect(Bun.stripANSI(line)).toBe("/usr/bin/bash --login -l notes.txt");
1899
+ expect(line).toContain(wrap("path", "/usr/bin/"));
1900
+ expect(line).toContain(wrap("type", "bash"));
1901
+ expect(line).toContain(wrap("statement", "--login"));
1902
+ expect(line).toContain(wrap("statement", "-l"));
1903
+ // An ordinary operand is left alone.
1904
+ expect(line).toContain(" notes.txt");
1905
+ // Word boundaries are micro's: the `sh` ending `script.sh` is one of
1906
+ // sh.yaml's command names, and it is painted like one here too.
1907
+ expect(colorProcessCommand("bun run script.sh")).toContain(wrap("type", "sh"));
1908
+
1909
+ // A program sh.yaml's word lists have never heard of is still the
1910
+ // command, because here the first token is known to be one.
1911
+ expect(colorProcessCommand("fish")).toBe(wrap("type", "fish"));
1912
+ expect(colorProcessCommand("")).toBe("");
1913
+ });
1914
+
1915
+ test("applies sh.yaml's rules to the rest of the command line", () => {
1916
+ const source = 'if test 42 = "$HOME"; then echo ok; fi # note';
1917
+ const line = highlightShellCommand(source);
1918
+ expect(Bun.stripANSI(line)).toBe(source);
1919
+ expect(line).toContain(wrap("statement", "if"));
1920
+ expect(line).toContain(wrap("statement", "then"));
1921
+ expect(line).toContain(wrap("constant", "42"));
1922
+ expect(line).toContain(wrap("type", "test"));
1923
+ expect(line).toContain(wrap("special", "="));
1924
+ // The string is a region, so it swallows the $HOME inside it.
1925
+ expect(line).toContain(wrap("string", '"$HOME"'));
1926
+ expect(line).toContain(wrap("comment", "# note"));
1927
+ expect(highlightShellCommand("run $HOME/bin ${PATH}"))
1928
+ .toContain(wrap("identifier", "$HOME"));
1929
+ });
1930
+
1931
+ test("resolves rule precedence the way micro does", () => {
1932
+ // The flag rule is listed after the command names, so it wins the
1933
+ // overlap: --cat is a flag, not the coreutils cat.
1934
+ expect(highlightShellCommand("bun --cat run")).toContain(wrap("statement", "--cat"));
1935
+ // A # inside a quoted argument does not open a comment.
1936
+ const quoted = highlightShellCommand('echo "a # b" done');
1937
+ expect(quoted).toContain(wrap("string", '"a # b"'));
1938
+ expect(quoted).toContain(wrap("statement", "done"));
1939
+ });
1940
+
1941
+ test("lets a kernel thread and a quoted program keep their own colour", () => {
1942
+ expect(colorProcessCommand("[kworker/0:1]")).toBe(wrap("path", "[kworker/0:1]"));
1943
+ const windows = colorProcessCommand('"C:\\Program Files\\App\\app.exe" --flag');
1944
+ expect(Bun.stripANSI(windows)).toBe('"C:\\Program Files\\App\\app.exe" --flag');
1945
+ expect(windows).toContain(wrap("string", '"C:\\Program Files\\App\\app.exe"'));
1946
+ expect(windows).toContain(wrap("statement", "--flag"));
1947
+ });
1948
+
1949
+ test("strips back to exactly what pspa prints", () => {
1950
+ const rows = [
1951
+ { pid: 1, args: "/init" },
1952
+ { pid: 4, args: "" },
1953
+ { pid: 123456, args: "/usr/bin/bun run dev" },
1954
+ ];
1955
+ expect(Bun.stripANSI(colorProcessTable(rows))).toBe(formatProcessTable(rows));
1956
+ });
1957
+
1958
+ test("lists the same processes pspa does, in colour", async () => {
1959
+ const execution = await executeArgv(["builtin", "pspac"], createState(), { capture: true });
1960
+ expect(execution.status).toBe(0);
1961
+ const text = decode(execution.stdout);
1962
+ expect(text).toContain("\x1b[");
1963
+ const lines = Bun.stripANSI(text).split("\n");
1964
+ expect(lines[0]).toMatch(/^\s*PID COMMAND$/);
1965
+ expect(lines.some((line) => new RegExp(`^\\s*${process.pid} `).test(line))).toBe(true);
1966
+ });
1967
+
1968
+ test("takes no operands", async () => {
1969
+ const execution = await executeArgv(["builtin", "pspac", "extra"], createState(), { capture: true });
1970
+ expect(execution.status).toBe(2);
1971
+ expect(decode(execution.stderr)).toBe("bunmsh: pspac: unexpected operand: extra\n");
1972
+ });
1973
+ });
1974
+
1975
+ describe("variable completion", () => {
1976
+ const state = { env: { HOME: "/home/user", HOSTNAME: "box", PATH: "/bin", LOCAL_ONLY: "x" } };
1977
+
1978
+ test("recognizes a name being typed after $, ${, and ${#", () => {
1979
+ expect(variableContext("echo $HO")).toMatchObject({ prefix: "HO", lead: "$", brace: false });
1980
+ expect(variableContext("echo ${HO")).toMatchObject({ prefix: "HO", lead: "${", brace: true });
1981
+ expect(variableContext("echo ${#HO")).toMatchObject({ prefix: "HO", lead: "${#", brace: true });
1982
+ // A bare $ is a name with nothing typed yet, not a non-match.
1983
+ expect(variableContext("echo $")).toMatchObject({ prefix: "", lead: "$" });
1984
+ // Position on the line does not matter: inside double quotes, on the
1985
+ // right of an assignment, or where a command name would go.
1986
+ expect(variableContext('echo "$HO')).toMatchObject({ prefix: "HO" });
1987
+ expect(variableContext("X=$HO")).toMatchObject({ prefix: "HO" });
1988
+ expect(variableContext("$ED")).toMatchObject({ prefix: "ED" });
1989
+ });
1990
+
1991
+ test("leaves alone every other thing a $ can start", () => {
1992
+ expect(variableContext("echo $(")).toBeNull();
1993
+ expect(variableContext("echo $(l")).toBeNull();
1994
+ expect(variableContext("echo $?")).toBeNull();
1995
+ expect(variableContext("echo $1")).toBeNull();
1996
+ // $$ is the pid, already complete; the second $ is not a name opening.
1997
+ expect(variableContext("echo $$")).toBeNull();
1998
+ expect(variableContext("echo hi")).toBeNull();
1999
+ expect(variableContext("echo $HOME ")).toBeNull();
2000
+ });
2001
+
2002
+ test("counts backslashes so an escaped dollar stays literal", () => {
2003
+ expect(variableContext(String.raw`echo \$HO`)).toBeNull();
2004
+ // An escaped backslash is not escaping the dollar.
2005
+ expect(variableContext(String.raw`echo \\$HO`)).toMatchObject({ prefix: "HO" });
2006
+ expect(variableContext(String.raw`echo \\\$HO`)).toBeNull();
2007
+ });
2008
+
2009
+ test("completes from the shell's own table, exported or not", () => {
2010
+ const index = new VariableIndex();
2011
+ expect(index.matches(state, "HO")).toEqual(["HOME", "HOSTNAME"]);
2012
+ expect(index.first(state, "LOCAL")).toBe("LOCAL_ONLY");
2013
+ expect(index.first(state, "ZZ")).toBeNull();
2014
+ // An empty prefix lists everything, the way Tab on an empty word does.
2015
+ expect(index.matches(state, "")).toEqual(["HOME", "HOSTNAME", "LOCAL_ONLY", "PATH"]);
2016
+ expect(index.first(state, "")).toBeNull();
2017
+ });
2018
+
2019
+ test("picks up a name added after the index was first built", () => {
2020
+ const index = new VariableIndex();
2021
+ const live = { env: { ...state.env } };
2022
+ expect(index.first(live, "TA")).toBeNull();
2023
+ live.env.TAG = "v1";
2024
+ expect(index.first(live, "TA")).toBe("TAG");
2025
+ });
2026
+
2027
+ test("closes the brace it was given", () => {
2028
+ expect(variableCompletion(variableContext("echo $HO"), "HOME")).toBe("$HOME");
2029
+ expect(variableCompletion(variableContext("echo ${HO"), "HOME")).toBe("${HOME}");
2030
+ expect(variableCompletion(variableContext("echo ${#HO"), "HOME")).toBe("${#HOME}");
2031
+ });
2032
+
2033
+ test("reports the quote a word is inside, so nothing expands in single quotes", () => {
2034
+ expect(completionContext("echo 'foo")).toMatchObject({ quote: "'" });
2035
+ expect(completionContext('echo "foo')).toMatchObject({ quote: '"' });
2036
+ expect(completionContext("echo foo")).toMatchObject({ quote: null });
2037
+ });
2038
+
2039
+ test("treats $( as a command position and ) as the end of one", () => {
2040
+ expect(completionContext("echo $(l")).toMatchObject({ command: true, prefix: "l" });
2041
+ expect(completionContext("echo $(ls /tm")).toMatchObject({ command: false, prefix: "/tm" });
2042
+ expect(completionContext("echo $(date) fi")).toMatchObject({ command: false, prefix: "fi" });
2043
+ });
2044
+ });
2045
+
2046
+ describe("javascript mode completion", () => {
2047
+ test("decides mode by the rule the evaluator dispatches on", () => {
2048
+ expect(isJavaScriptMode("Bun.e, 1")).toBe(true);
2049
+ expect(isJavaScriptMode(" Bun.file('x')")).toBe(true);
2050
+ expect(isJavaScriptMode("echo Bun.e")).toBe(false);
2051
+ });
2052
+
2053
+ test("stops offering a continuation a JavaScript line could never accept", () => {
2054
+ // A bare trailing backslash is a syntax error in JavaScript, not a
2055
+ // request for another line, so the prompt no longer asks for one.
2056
+ expect(needsMoreInput("Bun.e; const p = \\")).toBe(false);
2057
+ // An unterminated string still continues: that is JavaScript's own line
2058
+ // continuation, and the evaluator accepts it.
2059
+ expect(needsMoreInput('Bun.e, "abc\\')).toBe(true);
2060
+ expect(needsMoreInput("Bun.e, `abc")).toBe(true);
2061
+ // Shell text is untouched.
2062
+ expect(needsMoreInput("echo hi \\")).toBe(true);
2063
+ expect(needsMoreInput('echo "abc')).toBe(true);
2064
+ expect(needsMoreInput("echo hi")).toBe(false);
2065
+ });
2066
+
2067
+ test("completes a shell variable after $. and nothing after a bare $", () => {
2068
+ expect(javascriptContext("Bun.e, $.HO")).toMatchObject({ kind: "variable", prefix: "HO", lead: "$." });
2069
+ expect(javascriptContext("Bun.e, $.")).toMatchObject({ kind: "variable", prefix: "" });
2070
+ // In JavaScript a bare `$` is the table object itself; a name only
2071
+ // starts after the dot.
2072
+ expect(javascriptContext("Bun.e, $")).toBeNull();
2073
+ expect(javascriptContext("Bun.e, 1 + 2")).toBeNull();
2074
+ });
2075
+
2076
+ test("completes a path inside an unclosed string literal", () => {
2077
+ expect(javascriptContext('Bun.e, Bun.file("/tm')).toMatchObject({ kind: "path", prefix: "/tm", quote: '"' });
2078
+ expect(javascriptContext("Bun.e, Bun.file('/tm")).toMatchObject({ kind: "path", prefix: "/tm", quote: "'" });
2079
+ expect(javascriptContext("Bun.e, Bun.file(`/tm")).toMatchObject({ kind: "path", prefix: "/tm", quote: "`" });
2080
+ // A closed string is not a path being typed.
2081
+ expect(javascriptContext('Bun.e, Bun.file("/tmp/x")')).toBeNull();
2082
+ // An escaped quote does not close it.
2083
+ expect(javascriptContext('Bun.e, "it\\"s /tm')).toMatchObject({ kind: "path", prefix: 'it\\"s /tm' });
2084
+ });
2085
+
2086
+ test("treats a template interpolation as code again", () => {
2087
+ // Inside ${ } the cursor is back in JavaScript, so $. completes there.
2088
+ expect(javascriptContext("Bun.e, `a${$.HO")).toMatchObject({ kind: "variable", prefix: "HO" });
2089
+ // And the template resumes after the closing brace.
2090
+ expect(javascriptContext("Bun.e, `a${b}/tm")).toMatchObject({ kind: "path", quote: "`" });
2091
+ });
2092
+ });
package/bin/install.js CHANGED
@@ -195,14 +195,20 @@ const DIFF_TIMEOUT_MS = 60_000;
195
195
  // the user changed. Files it calls added are theirs and are never deleted by
196
196
  // an update; only modified ones are about to be written over.
197
197
  async function findLocalChanges(destination, version) {
198
- const child = Bun.spawn(
199
- [
200
- process.execPath, "pm", "diff",
201
- `${pkg.name}@${version}`, destination,
202
- "--name-only", "--json",
203
- ],
204
- { stdin: "ignore", stdout: "pipe", stderr: "pipe" },
205
- );
198
+ const argv = [
199
+ process.execPath, "pm", "diff",
200
+ `${pkg.name}@${version}`, destination,
201
+ "--name-only", "--json",
202
+ ];
203
+
204
+ // On a line of its own, unprefixed, and on stdout rather than stderr with
205
+ // the rest of the output, so it stands out and `2>/dev/null` leaves just the
206
+ // command: copy it and run it by hand without --json to read the list, or
207
+ // without --name-only to see the changes themselves rather than which files
208
+ // hold them.
209
+ console.log(` ${argv.join(" ")}`);
210
+
211
+ const child = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
206
212
 
207
213
  let timedOut = false;
208
214
  const timer = setTimeout(() => {
@@ -241,14 +247,28 @@ async function findLocalChanges(destination, version) {
241
247
  return { files: null, error: "bun pm diff did not return JSON" };
242
248
  }
243
249
 
250
+ const modified = (report.files ?? [])
251
+ .filter((file) => file.status === "modified" && !MERGED_PATHS.has(file.path));
252
+
244
253
  return {
245
- files: (report.files ?? [])
246
- .filter((file) => file.status === "modified" && !MERGED_PATHS.has(file.path))
247
- .map((file) => file.path),
254
+ files: modified.filter((file) => !isModeOnlyChange(file)).map((file) => file.path),
255
+ modeOnly: modified.filter(isModeOnlyChange).length,
248
256
  error: null,
249
257
  };
250
258
  }
251
259
 
260
+ // Publishing normalises file modes, so a file can come back from the registry
261
+ // with a different executable bit and identical contents. bun pm diff calls
262
+ // that a modification, and reporting it would mean a freshly installed tree
263
+ // listing files nobody has touched. modeBefore and modeAfter are only present
264
+ // when the mode differs at all; the rest is what says the bytes did not.
265
+ function isModeOnlyChange(file) {
266
+ return file.modeBefore !== undefined &&
267
+ file.linesAdded === 0 &&
268
+ file.linesRemoved === 0 &&
269
+ file.bytesBefore === file.bytesAfter;
270
+ }
271
+
252
272
  // Returns null when there is no terminal to ask at, which the caller reports
253
273
  // rather than treating as either answer.
254
274
  async function confirm(question) {
@@ -426,7 +446,7 @@ async function confirmLocalChanges(destination, version, options) {
426
446
  // sit there for a while with nothing on screen to explain the wait.
427
447
  console.error(`${pkg.name}: comparing it against the published ${version} for local changes...`);
428
448
 
429
- const { files, error } = await findLocalChanges(destination, version);
449
+ const { files, modeOnly, error } = await findLocalChanges(destination, version);
430
450
 
431
451
  // The check needs the registry, so it cannot run offline or against a version
432
452
  // that was never published. Whether anything was edited locally is then
@@ -448,6 +468,15 @@ async function confirmLocalChanges(destination, version, options) {
448
468
  return false;
449
469
  }
450
470
 
471
+ // Worth saying rather than hiding: it explains why the count here is smaller
472
+ // than the one the printed command reports, and a mode that keeps coming back
473
+ // is worth knowing about even though an update is not what changed it.
474
+ if (modeOnly) {
475
+ console.error(
476
+ `${pkg.name}: ${modeOnly} file(s) differ only in file mode, with identical contents; ignoring them`,
477
+ );
478
+ }
479
+
451
480
  if (!files.length) return false;
452
481
 
453
482
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Buninu is a portable, self-bootstrapping Unix-like userspace built on Bun that runs across operating systems",
5
5
  "type": "module",
6
6
  "main": "./bin/init.js",