buninu-linux 0.2.1 → 0.3.1

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 (42) hide show
  1. package/README.md +547 -190
  2. package/build-uki.sh +4 -2
  3. package/fetch-alpine.sh +78 -43
  4. package/index.js +4 -1
  5. package/initramfs/bin/ip +630 -0
  6. package/initramfs/bin/mount +401 -0
  7. package/initramfs/bin/poweroff +10 -0
  8. package/initramfs/bin/ps +256 -0
  9. package/initramfs/bin/reboot +10 -0
  10. package/initramfs/bin/tar +169 -0
  11. package/initramfs/bin/top +129 -0
  12. package/initramfs/bin/umount +71 -0
  13. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +36 -0
  14. package/initramfs/buninu/apps/bunmsh/README.md +7 -0
  15. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  16. package/initramfs/buninu/apps/bunmsh/src/fancy-ls.js +29 -14
  17. package/initramfs/buninu/apps/bunmsh/src/line-edit.js +43 -0
  18. package/initramfs/buninu/apps/bunmsh/src/main.js +15 -1
  19. package/initramfs/buninu/apps/bunmsh/src/mouse.js +29 -4
  20. package/initramfs/buninu/apps/bunmsh/src/shell.js +9 -1
  21. package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +70 -0
  22. package/initramfs/buninu/apps/jsmdcui/CHANGELOG.md +10 -0
  23. package/initramfs/buninu/apps/jsmdcui/README.md +2 -0
  24. package/initramfs/buninu/apps/jsmdcui/package.json +1 -1
  25. package/initramfs/buninu/apps/jsmdcui/runtime/help/commands.md +4 -1
  26. package/initramfs/buninu/apps/jsmdcui/runtime/help/help.md +2 -0
  27. package/initramfs/buninu/apps/jsmdcui/src/index.js +40 -13
  28. package/initramfs/buninu/package.json +1 -1
  29. package/initramfs/init.js +164 -11
  30. package/initramfs/lib/dlopen.js +121 -0
  31. package/initramfs/lib/document.js +6 -0
  32. package/initramfs/lib/modprobe.js +235 -0
  33. package/initramfs/lib/power.js +35 -0
  34. package/initramfs/usr/share/doc/buninu-linux/ip.md +155 -0
  35. package/initramfs/usr/share/doc/buninu-linux/mount.md +155 -0
  36. package/initramfs/usr/share/doc/buninu-linux/poweroff.md +15 -0
  37. package/initramfs/usr/share/doc/buninu-linux/ps.md +94 -0
  38. package/initramfs/usr/share/doc/buninu-linux/reboot.md +15 -0
  39. package/initramfs/usr/share/doc/buninu-linux/tar.md +73 -0
  40. package/initramfs/usr/share/doc/buninu-linux/top.md +23 -0
  41. package/initramfs/usr/share/doc/buninu-linux/umount.md +48 -0
  42. package/package.json +2 -2
@@ -0,0 +1,10 @@
1
+ #!/bin/bun
2
+ // reboot — thin wrapper over the shared sync-and-reboot implementation.
3
+
4
+ import { LINUX_REBOOT_CMD_RESTART, runPowerCommand } from "../lib/power.js";
5
+
6
+ await runPowerCommand({
7
+ name: "reboot",
8
+ command: { name: "LINUX_REBOOT_CMD_RESTART", value: LINUX_REBOOT_CMD_RESTART },
9
+ action: "restarting",
10
+ });
@@ -0,0 +1,169 @@
1
+ #!/bin/bun
2
+ // tar — a small tar/gzip/zstd CLI over Bun.Archive.
3
+
4
+ import { lstat, readdir } from "node:fs/promises";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { showDocument } from "../lib/document.js";
7
+
8
+ const fail = (message) => {
9
+ console.error(`tar: ${message}`);
10
+ process.exit(2);
11
+ };
12
+
13
+ const usage = () => {
14
+ console.error(`Usage:
15
+ tar -cf ARCHIVE FILE...
16
+ tar -xf ARCHIVE [-C DIR] [MEMBER...]
17
+ tar -tf ARCHIVE [MEMBER...]
18
+ tar -h | --help
19
+
20
+ Try 'tar --help' for the full manual.`);
21
+ process.exit(2);
22
+ };
23
+
24
+ const options = { operation: null, archive: null, directory: ".", compression: null, verbose: false, operands: [] };
25
+ const argv = process.argv.slice(2);
26
+ if (argv.length === 0) usage();
27
+ const take = (index, option) => {
28
+ if (index + 1 >= argv.length) fail(`${option} requires an argument`);
29
+ return argv[index + 1];
30
+ };
31
+
32
+ for (let index = 0; index < argv.length; index++) {
33
+ const argument = argv[index];
34
+ const oldStyleOptions = index === 0 && /^[cxtrvfz]+$/.test(argument)
35
+ && /[cxtr]/.test(argument);
36
+ if (argument === "--") { options.operands.push(...argv.slice(index + 1)); break; }
37
+ if (argument === "-h" || argument === "--help") {
38
+ await showDocument("tar");
39
+ process.exit(0);
40
+ }
41
+ if (argument === "--zstd") { options.compression = "zstd"; continue; }
42
+ if (argument === "--gzip") { options.compression = "gzip"; continue; }
43
+ if (argument === "-C" || argument === "--directory") {
44
+ options.directory = take(index++, argument);
45
+ continue;
46
+ }
47
+ if (argument.startsWith("--directory=")) {
48
+ options.directory = argument.slice(12);
49
+ continue;
50
+ }
51
+ if (argument === "-f" || argument === "--file") {
52
+ options.archive = take(index++, argument);
53
+ continue;
54
+ }
55
+ if (argument.startsWith("--file=")) {
56
+ options.archive = argument.slice(7);
57
+ continue;
58
+ }
59
+ if ((argument.startsWith("-") && argument !== "-") || oldStyleOptions) {
60
+ const flags = oldStyleOptions ? argument : argument.slice(1);
61
+ for (let offset = 0; offset < flags.length; offset++) {
62
+ const flag = flags[offset];
63
+ if ("cxt".includes(flag)) {
64
+ if (options.operation && options.operation !== flag) fail("only one of -c, -x, or -t may be used");
65
+ options.operation = flag;
66
+ } else if (flag === "r") {
67
+ fail("-r append is not supported by Bun.Archive");
68
+ } else if (flag === "v") options.verbose = true;
69
+ else if (flag === "z") options.compression = "gzip";
70
+ else if (flag === "f") {
71
+ const attached = flags.slice(offset + 1);
72
+ options.archive = attached || take(index++, "-f");
73
+ break;
74
+ } else fail(`unknown option -- ${flag}`);
75
+ }
76
+ continue;
77
+ }
78
+ options.operands.push(argument);
79
+ }
80
+
81
+ if (!options.operation) fail("one of -c, -x, or -t is required");
82
+ if (!options.archive) fail("an archive must be specified with -f");
83
+
84
+ const archiveBytes = async () => {
85
+ const bytes = options.archive === "-"
86
+ ? await Bun.stdin.bytes()
87
+ : await Bun.file(options.archive).bytes();
88
+ const zstd = options.compression === "zstd" || /\.(?:zst|tzst)$/i.test(options.archive);
89
+ return zstd ? Bun.zstdDecompressSync(bytes) : bytes;
90
+ };
91
+
92
+ const patterns = () => options.operands.flatMap((name) => {
93
+ const clean = name.replace(/^\.\//, "").replace(/\/$/, "");
94
+ return [clean, `${clean}/**`];
95
+ });
96
+
97
+ const verboseListing = (name, file) => {
98
+ const modified = Number.isFinite(file.lastModified) && file.lastModified > 0
99
+ ? new Date(file.lastModified).toISOString().replace("T", " ").slice(0, 16)
100
+ : "---- -- -- --:--";
101
+ return `${String(file.size).padStart(12)} ${modified} ${name}`;
102
+ };
103
+
104
+ if (options.operation === "x" || options.operation === "t") {
105
+ let archive;
106
+ try {
107
+ archive = new Bun.Archive(await archiveBytes());
108
+ } catch (error) {
109
+ fail(error?.message || String(error));
110
+ }
111
+
112
+ if (options.operation === "t") {
113
+ try {
114
+ const files = await archive.files(options.operands.length ? patterns() : undefined);
115
+ for (const [name, file] of files) {
116
+ console.log(options.verbose ? verboseListing(name, file) : name);
117
+ }
118
+ } catch (error) {
119
+ fail(error?.message || String(error));
120
+ }
121
+ } else {
122
+ try {
123
+ if (options.verbose) {
124
+ const files = await archive.files(options.operands.length ? patterns() : undefined);
125
+ for (const name of files.keys()) console.log(name);
126
+ }
127
+ await archive.extract(options.directory, options.operands.length ? { glob: patterns() } : undefined);
128
+ } catch (error) {
129
+ fail(error?.message || String(error));
130
+ }
131
+ }
132
+ process.exit(0);
133
+ }
134
+
135
+ if (options.operands.length === 0) fail("refusing to create an empty archive");
136
+ const root = resolve(options.directory);
137
+ const inputs = {};
138
+
139
+ const add = async (diskPath, archivePath) => {
140
+ const info = await lstat(diskPath);
141
+ if (info.isSymbolicLink()) fail(`${archivePath}: creating symbolic links is not supported by Bun.Archive`);
142
+ if (info.isDirectory()) {
143
+ for (const entry of await readdir(diskPath)) await add(join(diskPath, entry), `${archivePath}/${entry}`);
144
+ return;
145
+ }
146
+ if (!info.isFile()) fail(`${archivePath}: unsupported file type`);
147
+ const name = archivePath.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "");
148
+ if (!name || name === ".." || name.startsWith("../") || name.includes("/../")) fail(`${archivePath}: unsafe archive path`);
149
+ // Bun 1.4.3 accepts Blob in ArchiveInput, but a lazy BunFile is currently
150
+ // archived as zero bytes. Materialise it until Bun.Archive handles BunFile
151
+ // contents directly.
152
+ inputs[name] = await Bun.file(diskPath).bytes();
153
+ if (options.verbose) console.error(name);
154
+ };
155
+
156
+ try {
157
+ for (const operand of options.operands) {
158
+ const diskPath = resolve(root, operand);
159
+ const name = operand.startsWith("/") ? basename(operand) : operand.replace(/\/$/, "");
160
+ await add(diskPath, name);
161
+ }
162
+ const archive = new Bun.Archive(inputs, options.compression === "gzip" ? { compress: "gzip" } : undefined);
163
+ const output = options.compression === "zstd"
164
+ ? Bun.zstdCompressSync(await archive.bytes())
165
+ : archive;
166
+ await Bun.write(options.archive === "-" ? Bun.stdout : options.archive, output);
167
+ } catch (error) {
168
+ fail(error?.message || String(error));
169
+ }
@@ -0,0 +1,129 @@
1
+ #!/bin/bun
2
+ // top — periodically render snapshots and process lines supplied by ps.
3
+
4
+ import { showDocument } from "../lib/document.js";
5
+ import {
6
+ CLOCK_TICKS,
7
+ formatProcessHeader,
8
+ formatProcessLine,
9
+ prepareProcessFormat,
10
+ readProcessSnapshot,
11
+ } from "./ps";
12
+
13
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
14
+ const fields = ["pid", "user", "pri", "ni", "vsz", "rss", "state", "%cpu", "%mem", "time", "comm"];
15
+
16
+ const uptimeText = (seconds) => {
17
+ const days = Math.floor(seconds / 86400);
18
+ const hours = Math.floor(seconds % 86400 / 3600);
19
+ const minutes = Math.floor(seconds % 3600 / 60);
20
+ return `${days ? `${days} day${days === 1 ? "" : "s"}, ` : ""}${hours}:${String(minutes).padStart(2, "0")}`;
21
+ };
22
+
23
+ const memoryText = (memory) => {
24
+ const total = memory.MemTotal ?? 0;
25
+ const free = memory.MemFree ?? 0;
26
+ const cache = (memory.Buffers ?? 0) + (memory.Cached ?? 0) + (memory.SReclaimable ?? 0);
27
+ return `MiB Mem : ${(total / 1024).toFixed(1)} total, ${(free / 1024).toFixed(1)} free, ${((total - free - cache) / 1024).toFixed(1)} used, ${(cache / 1024).toFixed(1)} buff/cache`;
28
+ };
29
+
30
+ const intervalRows = (snapshot, previous) => {
31
+ if (!previous) return snapshot.processes;
32
+ const elapsed = Math.max(0.001, (snapshot.at - previous.at) / 1000);
33
+ const oldTicks = new Map(previous.processes.map((row) => [row.pid, row.ticks]));
34
+ return snapshot.processes.map((row) => ({
35
+ ...row,
36
+ cpu: Math.max(0, row.ticks - (oldTicks.get(row.pid) ?? row.ticks)) / CLOCK_TICKS / elapsed * 100,
37
+ }));
38
+ };
39
+
40
+ const render = (snapshot, previous, sort, batch) => {
41
+ const rows = intervalRows(snapshot, previous);
42
+ rows.sort(sort === "memory"
43
+ ? (a, b) => b.memory - a.memory || a.pid - b.pid
44
+ : sort === "pid" ? (a, b) => a.pid - b.pid : (a, b) => b.cpu - a.cpu || a.pid - b.pid);
45
+ const states = { running: 0, sleeping: 0, stopped: 0, zombie: 0 };
46
+ for (const row of rows) {
47
+ if (row.state[0] === "R") states.running++;
48
+ else if (row.state[0] === "T") states.stopped++;
49
+ else if (row.state[0] === "Z") states.zombie++;
50
+ else states.sleeping++;
51
+ }
52
+ const total = previous ? snapshot.cpu.total - previous.cpu.total : snapshot.cpu.total;
53
+ const idleTicks = previous ? snapshot.cpu.idle - previous.cpu.idle : snapshot.cpu.idle;
54
+ const idle = total > 0 ? idleTicks / total * 100 : 0;
55
+ const clock = new Date().toLocaleTimeString("en-US", { hour12: false });
56
+ const lines = [
57
+ `top - ${clock} up ${uptimeText(snapshot.uptime)}, load average: ${snapshot.loadAverage.map((v) => v.toFixed(2)).join(", ")}`,
58
+ `Tasks: ${rows.length} total, ${states.running} running, ${states.sleeping} sleeping, ${states.stopped} stopped, ${states.zombie} zombie`,
59
+ `%Cpu(s): ${(100 - idle).toFixed(1)} used, ${idle.toFixed(1)} id`,
60
+ memoryText(snapshot.memory),
61
+ "",
62
+ ];
63
+ const format = prepareProcessFormat(rows, fields);
64
+ lines.push(formatProcessHeader(format));
65
+ // Some early boot consoles report a TTY size of 0x0. Treat that as
66
+ // unknown; otherwise Math.max(1, 0 - headers) displays only one process
67
+ // and makes it look as if the snapshot contained only top itself.
68
+ const terminalRows = Number(process.stdout.rows);
69
+ const screenRows = Number.isFinite(terminalRows) && terminalRows > lines.length
70
+ ? terminalRows
71
+ : 24;
72
+ const limit = batch ? rows.length : Math.max(1, screenRows - lines.length);
73
+ for (const row of rows.slice(0, limit)) lines.push(formatProcessLine(row, format));
74
+ return lines.join("\n");
75
+ };
76
+
77
+ async function main(argv) {
78
+ let delay = 3;
79
+ let iterations = Infinity;
80
+ let batch = false;
81
+ for (let index = 0; index < argv.length; index++) {
82
+ const argument = argv[index];
83
+ if (argument === "-h" || argument === "--help") return showDocument("top");
84
+ if (argument === "-b") { batch = true; continue; }
85
+ if (argument === "-d") { delay = Number(argv[++index]); continue; }
86
+ if (argument === "-n") { iterations = Number(argv[++index]); continue; }
87
+ throw new Error(`top: unsupported option '${argument}'`);
88
+ }
89
+ if (!(delay > 0) || !(iterations > 0)) throw new Error("top: -d and -n require positive numbers");
90
+ let quit = false;
91
+ let sort = "cpu";
92
+ const interactive = !batch && process.stdin.isTTY && process.stdout.isTTY;
93
+ const cleanup = () => {
94
+ if (interactive) {
95
+ process.stdin.setRawMode(false);
96
+ process.stdin.pause();
97
+ process.stdout.write("\x1b[?25h\n");
98
+ }
99
+ };
100
+ if (interactive) {
101
+ process.stdin.setRawMode(true);
102
+ process.stdin.resume();
103
+ process.stdin.setEncoding("utf8");
104
+ process.stdin.on("data", (key) => {
105
+ if (key === "q" || key === "\u0003") quit = true;
106
+ else if (key === "P") sort = "cpu";
107
+ else if (key === "M") sort = "memory";
108
+ else if (key === "N") sort = "pid";
109
+ });
110
+ process.stdout.write("\x1b[?25l");
111
+ process.on("exit", cleanup);
112
+ } else if (!batch && iterations === Infinity) iterations = 1;
113
+
114
+ let previous = null;
115
+ try {
116
+ for (let count = 0; count < iterations && !quit; count++) {
117
+ const snapshot = readProcessSnapshot();
118
+ const output = render(snapshot, previous, sort, batch);
119
+ process.stdout.write(`${interactive ? "\x1b[H\x1b[2J" : ""}${output}\n`);
120
+ previous = snapshot;
121
+ if (count + 1 < iterations && !quit) await sleep(delay * 1000);
122
+ }
123
+ } finally { cleanup(); }
124
+ }
125
+
126
+ if (import.meta.main) {
127
+ try { await main(process.argv.slice(2)); }
128
+ catch (error) { console.error(error.message); process.exit(1); }
129
+ }
@@ -0,0 +1,71 @@
1
+ #!/bin/bun
2
+ // umount — unmount a filesystem. `umount --help` renders
3
+ // /usr/share/doc/buninu-linux/umount.md.
4
+
5
+ import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
6
+ import { showDocument } from "../lib/document.js";
7
+
8
+ const MNT = { FORCE: 1, DETACH: 2, EXPIRE: 4, NOFOLLOW: 8 };
9
+
10
+ const usage = () => {
11
+ console.error(`Usage:
12
+ umount [-f] [-l] [-R] [-v] TARGET...
13
+ umount -h | --help`);
14
+ process.exit(1);
15
+ };
16
+
17
+ let flags = 0;
18
+ let recursive = false;
19
+ let verbose = false;
20
+ let help = false;
21
+ const targets = [];
22
+ for (const argument of process.argv.slice(2)) {
23
+ if (argument === "-h" || argument === "--help") help = true;
24
+ else if (argument === "-f" || argument === "--force") flags |= MNT.FORCE;
25
+ else if (argument === "-l" || argument === "--lazy") flags |= MNT.DETACH;
26
+ else if (argument === "-R" || argument === "--recursive") recursive = true;
27
+ else if (argument === "-v" || argument === "--verbose") verbose = true;
28
+ else if (argument === "-n" || argument === "-d" || argument === "--no-mtab" || argument === "--detach-loop") {}
29
+ else if (argument === "-a" || argument === "--all") { console.error("umount: -a is not supported"); process.exit(1); }
30
+ else if (argument.startsWith("-") && argument.length > 1) { console.error(`umount: unknown option ${argument}`); usage(); }
31
+ else targets.push(argument);
32
+ }
33
+
34
+ if (help) await showDocument("umount");
35
+ else if (targets.length === 0) usage();
36
+
37
+ const mountPoints = () => {
38
+ const table = Bun.file("/proc/self/mounts");
39
+ return table.text().then((text) =>
40
+ text.split("\n").filter(Boolean).map((line) =>
41
+ line.split(" ")[1].replace(/\\([0-7]{3})/g, (_, code) => String.fromCharCode(parseInt(code, 8))),
42
+ ),
43
+ );
44
+ };
45
+
46
+ const unmount = (target) => {
47
+ const result = libc.symbols.umount2(ptr(cString(target)), flags);
48
+ if (result === 0) {
49
+ if (verbose) console.log(`${target} unmounted`);
50
+ return true;
51
+ }
52
+ const code = errno();
53
+ const reason = code === Errno.EBUSY ? "target is busy (try -l for a lazy unmount)"
54
+ : code === Errno.EINVAL ? "not mounted"
55
+ : code === Errno.EPERM ? "must be superuser to unmount"
56
+ : strerror(code);
57
+ console.error(`umount: ${target}: ${reason}.`);
58
+ return false;
59
+ };
60
+
61
+ let status = 0;
62
+ for (const target of targets) {
63
+ const path = target.length > 1 ? target.replace(/\/+$/, "") : target;
64
+ // -R takes every mount at or below the target, deepest first, so each
65
+ // umount2 sees an empty subtree.
66
+ const list = recursive
67
+ ? (await mountPoints()).filter((point) => point === path || point.startsWith(`${path}/`)).sort((a, b) => b.length - a.length)
68
+ : [path];
69
+ for (const point of list) if (!unmount(point)) status = 32;
70
+ }
71
+ process.exit(status);
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable user-visible changes to bunmsh are documented here.
4
4
 
5
+ ## [0.3.9] - 2026-09-22
6
+
7
+ ### Fixed
8
+
9
+ - Make pathname expansion follow directory symbolic links used as intermediate
10
+ path components, matching traditional shell behaviour. `Bun.Glob` disables
11
+ that traversal by default, so patterns such as `./*/*.js` silently skipped
12
+ a matching directory symlink, and sysfs patterns such as
13
+ `/sys/class/net/*/device` failed to pass through class-device links. bunmsh
14
+ now enables `followSymlinks` while scanning; a symlink matched as the final
15
+ component is still passed to the command as a symlink.
16
+
17
+ - Stop the fallback `ls`/`lsfancy` from printing every glob-expanded file as
18
+ a directory section. `builtin ls L*` previously expanded the glob
19
+ correctly, but multiple operands unconditionally received `filename:`
20
+ headings and blank separators. Only directories whose contents are being
21
+ listed now receive section headings; multiple ordinary files form one
22
+ continuous listing and retain their full operand paths rather than being
23
+ reduced to basenames.
24
+
25
+ - Honor `--` in `ls`/`lsfancy`, so names such as `-odd` after it are operands
26
+ rather than option clusters. Also follow a command-line symlink to a
27
+ directory for ordinary and recursive listings, as traditional `ls` does;
28
+ `-d`, `-l`, and `-F` continue to inspect the symlink itself.
29
+
30
+ ## [0.3.8] - 2026-09-21
31
+
32
+ ### Added
33
+
34
+ - Make `Ctrl-U` and `Ctrl-K` toggle their edits in the interactive line
35
+ editor. `Ctrl-U` saves the text removed before the cursor and restores it
36
+ when pressed again at the beginning of the line; `Ctrl-K` does the same for
37
+ text after the cursor when pressed again at the end. Each shortcut keeps an
38
+ independent saved value, and control bytes inside bracketed paste remain
39
+ literal pasted content rather than triggering either action.
40
+
5
41
  ## [0.3.7] - 2026-09-20
6
42
 
7
43
  ### Fixed
@@ -476,6 +476,13 @@ Bun.e, Bun.file("/tm # -> a path, completed inside the string
476
476
 
477
477
  - `Ctrl-D`: On an empty input line, sends EOF and exits the interactive shell.
478
478
 
479
+ - `Ctrl-U`: Removes and saves everything before the cursor. Press it again
480
+ while the cursor is at the beginning to restore that saved prefix.
481
+
482
+ - `Ctrl-K`: Removes and saves everything after the cursor. Press it again
483
+ while the cursor is at the end to restore that saved suffix. Its saved text
484
+ is independent from `Ctrl-U`.
485
+
479
486
  - `Ctrl-T`: Calls `builtin tab` without adding a command to history. It creates
480
487
  a tab when only one exists, or switches to the next tab otherwise. Any
481
488
  command text currently being edited is preserved.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmsh",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Cross-platform command shell for Bun JavaScript",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
- import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
2
- import { basename, isAbsolute, resolve } from "node:path";
1
+ import { existsSync, lstatSync, readdirSync, readlinkSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
3
 
4
4
  const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif", "heic"]);
5
5
  const MUSIC = new Set(["mp3", "wav", "flac", "ogg", "m4a", "aac", "opus", "mid", "midi"]);
@@ -120,9 +120,11 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
120
120
  recursive: false, time: false, reverse: false, size: false, one: false, classify: false,
121
121
  };
122
122
  const operands = [];
123
+ let parseOptions = true;
123
124
  for (const argument of argv.slice(1)) {
124
- if (argument === "--color=auto" || argument === "--color" || argument === "--") continue;
125
- if (/^-[^-]/.test(argument)) {
125
+ if (parseOptions && argument === "--") { parseOptions = false; continue; }
126
+ if (parseOptions && (argument === "--color=auto" || argument === "--color")) continue;
127
+ if (parseOptions && /^-[^-]/.test(argument)) {
126
128
  for (const flag of argument.slice(1)) {
127
129
  if (flag === "a") options.all = true;
128
130
  else if (flag === "A") options.almostAll = true;
@@ -142,15 +144,30 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
142
144
  if (!operands.length) operands.push(".");
143
145
  let status = 0, stdout = "", stderr = "";
144
146
  const visited = new Set();
145
- const show = (operand, heading = operands.length > 1) => {
147
+ let previousWasDirectory = false;
148
+ const show = (operand, heading = operands.length > 1, separate = true) => {
146
149
  const path = isAbsolute(operand) ? operand : resolve(state.cwd, operand);
147
150
  try {
148
151
  const stats = lstatSync(path);
152
+ let listingDirectory = stats.isDirectory() && !options.directory;
153
+ // GNU/POSIX-style ls follows a command-line symlink to a directory for
154
+ // an ordinary listing and for -R. -d, -l, and -F request information
155
+ // about the link itself instead. A broken link simply cannot qualify.
156
+ if (stats.isSymbolicLink() && !options.directory && !options.long && !options.classify) {
157
+ try { listingDirectory = statSync(path).isDirectory(); } catch {}
158
+ }
159
+ // Multiple file operands are one listing, not a series of directory
160
+ // sections. Only actual directory contents receive `name:` headings
161
+ // and blank section separators.
162
+ if (separate && stdout && (listingDirectory || previousWasDirectory)) stdout += "\n";
149
163
  let entries;
150
- if (!stats.isDirectory() || options.directory)
151
- entries = [{ name: basename(operand) || operand, stats, path }];
164
+ if (!listingDirectory)
165
+ // Preserve the operand spelling just like traditional ls. A glob such
166
+ // as ./*/*.js has already expanded to paths, and reducing each one to
167
+ // basename would lose the information that distinguishes its parent.
168
+ entries = [{ name: operand, stats, path }];
152
169
  else entries = listDirectory(path, options);
153
- if (heading) stdout += `${operand}:\n`;
170
+ if (heading && listingDirectory) stdout += `${operand}:\n`;
154
171
  const rendered = entries.map((entry) => displayEntry(entry, options));
155
172
  if (options.long) {
156
173
  for (let i = 0; i < entries.length; i++) {
@@ -164,23 +181,21 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
164
181
  } else stdout += terminal && !options.one
165
182
  ? columns(rendered, process.stdout.columns ?? 80)
166
183
  : `${rendered.join("\n")}${rendered.length ? "\n" : ""}`;
167
- if (options.recursive && stats.isDirectory() && !options.directory) {
184
+ if (options.recursive && listingDirectory) {
168
185
  for (const entry of entries) if (entry.stats.isDirectory() && entry.name !== "." && entry.name !== "..") {
169
186
  const child = resolve(path, entry.name);
170
187
  if (visited.has(child)) continue;
171
188
  visited.add(child);
172
189
  stdout += "\n";
173
- show(`${operand.replace(/\/$/, "")}/${entry.name}`, true);
190
+ show(`${operand.replace(/\/$/, "")}/${entry.name}`, true, false);
174
191
  }
175
192
  }
193
+ previousWasDirectory = listingDirectory;
176
194
  } catch (error) {
177
195
  status = 1;
178
196
  stderr += `bunmsh: ${argv[0]}: ${operand}: ${error.message}\n`;
179
197
  }
180
198
  };
181
- for (let i = 0; i < operands.length; i++) {
182
- if (i) stdout += "\n";
183
- show(operands[i]);
184
- }
199
+ for (const operand of operands) show(operand);
185
200
  return { status, stdout, stderr };
186
201
  }
@@ -0,0 +1,43 @@
1
+ export function toggleSavedText(line, cursor, saved, side) {
2
+ line = String(line ?? "");
3
+ cursor = Math.max(0, Math.min(line.length, Number.isFinite(cursor) ? cursor : line.length));
4
+ saved = String(saved ?? "");
5
+
6
+ if (side === "head") {
7
+ if (cursor > 0) {
8
+ return {
9
+ line: line.slice(cursor),
10
+ cursor: 0,
11
+ saved: line.slice(0, cursor),
12
+ changed: true,
13
+ };
14
+ }
15
+ if (saved) {
16
+ return {
17
+ line: saved + line,
18
+ cursor: saved.length,
19
+ saved: "",
20
+ changed: true,
21
+ };
22
+ }
23
+ } else if (side === "tail") {
24
+ if (cursor < line.length) {
25
+ return {
26
+ line: line.slice(0, cursor),
27
+ cursor,
28
+ saved: line.slice(cursor),
29
+ changed: true,
30
+ };
31
+ }
32
+ if (saved) {
33
+ return {
34
+ line: line + saved,
35
+ cursor,
36
+ saved: "",
37
+ changed: true,
38
+ };
39
+ }
40
+ }
41
+
42
+ return { line, cursor, saved, changed: false };
43
+ }
@@ -29,6 +29,7 @@ import { importedHistory, readlineHistory, saveBunmshHistory } from "./history.j
29
29
  import pkg from "../package.json" with { type:"json" }
30
30
  import { MOUSE_OFF, MOUSE_ON, PASTE_OFF, PASTE_ON, mouseInput } from "./mouse.js";
31
31
  import { homeRelativePath } from "./environment.js";
32
+ import { toggleSavedText } from "./line-edit.js";
32
33
 
33
34
  const VERSION = `
34
35
  ${pkg.name}: ${pkg.description}
@@ -223,6 +224,8 @@ async function interactive(state) {
223
224
  let lastTabClick = null;
224
225
  let mouseCommandRunning = false;
225
226
  let foregroundCommand = false;
227
+ let savedHead = "";
228
+ let savedTail = "";
226
229
  let promptAbort = null;
227
230
  // Set by onPaste (below) when a bracketed paste contains a newline, and
228
231
  // consumed by the main loop's readline.question() catch block right after
@@ -332,6 +335,17 @@ async function interactive(state) {
332
335
  const key = { name: delta > 0 ? "right" : "left" };
333
336
  for (let n = 0; n < Math.abs(delta); n++) readline.write(null, key);
334
337
  };
338
+ const toggleLineText = (side) => {
339
+ if (!readline || foregroundCommand) return;
340
+ const saved = side === "head" ? savedHead : savedTail;
341
+ const result = toggleSavedText(readline.line, readline.cursor, saved, side);
342
+ if (side === "head") savedHead = result.saved;
343
+ else savedTail = result.saved;
344
+ if (!result.changed) return;
345
+ readline.line = result.line;
346
+ readline.cursor = result.cursor;
347
+ readline.prompt(true);
348
+ };
335
349
  const filteredInput = terminal ? mouseInput((mouse) => {
336
350
  if (!mouse.press || (mouse.button & 3) !== 0 || (mouse.button & 32)) return;
337
351
  pendingClick = { ...mouse, at: Date.now() };
@@ -384,7 +398,7 @@ async function interactive(state) {
384
398
  else if (shortcut === "lsfancy") runFancyShortcut();
385
399
  else if (shortcut === "lsfancy-parent") runFancyShortcut([".."]);
386
400
  else if (shortcut === "tab-close") runTabShortcut(["x"]);
387
- }, onPaste) : process.stdin;
401
+ }, onPaste, toggleLineText) : process.stdin;
388
402
  const completer = (line) => {
389
403
  commandIndex.refreshIfChanged(state);
390
404
  if (javascriptLine(line)) {