buninu-linux 0.10.56 → 0.11.56

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.
package/NOTICE.md CHANGED
@@ -284,8 +284,9 @@ committed source files, but their contents are redistributed in the image.
284
284
 
285
285
  | Package | Version | Package license | Project / package page | Installed license and notices (relative to `/usr/lib/node_modules/`) |
286
286
  |---|---|---|---|---|
287
- | `buninu-browser` | 0.2.2 | MIT for the browser's own code; bundled components retain their own terms | https://github.com/jjtseng93/buninu-browser | `buninu-browser/LICENSE`, `THIRD_PARTY_NOTICES.md`, `LICENSES/` and vendor notices within that package |
288
- | `@drxiaozhi/casty` | 1.3.4 | MIT | https://github.com/jjtseng93/casty | `@drxiaozhi/casty/LICENSE` |
287
+ | `buninu-browser` | 0.2.3 | MIT for the browser's own code; bundled components retain their own terms | https://github.com/jjtseng93/buninu-browser | `buninu-browser/LICENSE`, `THIRD_PARTY_NOTICES.md`, `LICENSES/` and vendor notices within that package |
288
+ | `buninu-land` | 0.1.2 | MIT for its own code; bundled components retain their own terms | https://github.com/jjtseng93/buninu-land | `buninu-land/LICENSE`, `THIRD_PARTY_NOTICES.md`, `LICENSES/`, `SOURCES.md`, protocol XML notices and vendor notices |
289
+ | `@drxiaozhi/casty` | 1.3.6 | MIT | https://github.com/jjtseng93/casty | `@drxiaozhi/casty/LICENSE` |
289
290
  | `@drxiaozhi/jspulse` | 0.2.1 | LGPL-2.1-or-later, with additional component notices | https://github.com/jjtseng93/jspulse | `@drxiaozhi/jspulse/LICENSE`, `NOTICE.md`, `LICENSES/` and source-file notices within that package |
290
291
  | `@drxiaozhi/jspinyin` | 0.1.1 | MIT; hanzipinyin material under the Unlicense | https://www.npmjs.com/package/@drxiaozhi/jspinyin | `@drxiaozhi/jspinyin/LICENSE` and `@drxiaozhi/jspinyin/LICENSE-hanzipinyin.md` |
291
292
 
@@ -317,6 +318,17 @@ LGPL-2.1-or-later. Within `/usr/lib/node_modules/buninu-browser/`, retain:
317
318
  The decoder's `cpu.wasm` remains a separate, replaceable file. Do not remove
318
319
  the source archive or rebuild material merely to reduce the image size.
319
320
 
321
+ ### Buninu Land
322
+
323
+ The compositor package is extracted in full, including its CanvasKit WASM,
324
+ loader, protocol XML, source provenance, patches and license collection.
325
+ Retain `THIRD_PARTY_NOTICES.md`, `SOURCES.md`, `vendor-manifest.json`,
326
+ `LICENSES/`, `vendor/canvaskit/LICENSE`, and the notices embedded in the
327
+ Wayland protocol XML files. These cover Buninu Browser-derived code,
328
+ CanvasKit/Skia and embedded libraries, Wayland protocols, and the Hyprland
329
+ window-management/input reference credit (`LICENSES/hyprland.txt`).
330
+ The distribution-owned launcher uses the separately bundled Casty package.
331
+
320
332
  ### jspulse source and notices
321
333
 
322
334
  jspulse is shipped as JavaScript source, with its LGPL license, modification
package/README.md CHANGED
@@ -689,6 +689,7 @@ If there is no sound, check step 4's terminal for errors, and make sure
689
689
  | --- | --- | --- |
690
690
  | `buninu-linux-help` | render this buninu-linux README from `/usr/share/doc/buninu-linux/README.md` | `buninu-linux-help` |
691
691
  | `buninu-browser` | early-stage, experimental JavaScript/WASM browser with the bundled casty terminal frontend; not yet a general-purpose browser replacement | `buninu-browser --help` |
692
+ | `buninu-land` | experimental software-rendered Wayland compositor with the bundled casty terminal frontend | `buninu-land --help` |
692
693
  | `jspulse` | PulseAudio-compatible audio server and ALSA mixer controls; use `--alsa` on Buninu Linux | `jspulse --help` |
693
694
  | `jspinyin` | Chinese pinyin input panel, opened with the system jsmdcui in TUI mode; not a system-wide input method | — |
694
695
  | `mount` | `mount(2)` with type detection, `-o` parsing, `LABEL=`/`UUID=`, bind/move/remount; loads required filesystem and disk modules | `mount --help` → `/usr/share/doc/buninu-linux/mount.md` |
@@ -707,7 +708,7 @@ If there is no sound, check step 4's terminal for errors, and make sure
707
708
  | `mmd`, `mcopy`, `mdir` | create directories, copy files and list contents in a FAT image without mounting it; use `-i IMAGE` and `::/path` | — |
708
709
  | `parted` | create and inspect GPT partitions in a disk image; script-mode subset | `parted --help` |
709
710
  | `stripansi` | remove ANSI escape sequences from stdin, `-`, or one or more files and concatenate the results | — |
710
- | `chroot` | `chroot(2)` into another root directory, mounting `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, `/tmp` and `/etc/resolv.conf` for you and unmounting them afterwards | `chroot --help` |
711
+ | `chroot` | `chroot(2)` into another root directory, mounting `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, `/tmp` and `/etc/resolv.conf` for you; supports `-b HOST[:GUEST]` and `--shared-tmp`, with automatic cleanup on exit | `chroot --help` |
711
712
  | `bunterm` | a graphical terminal on the framebuffer: Skia (CanvasKit) text with CJK, colour emoji, seamless box drawing, kitty graphics images, with the mouse working, wheel scrollback included (`--no-mouse` turns it off); xterm.js's emulator core over Bun's built-in PTY | `bunterm --help` |
712
713
 
713
714
  Besides `bun` (and `sh`/`node` pointing at it), `/bin` includes the commands
@@ -738,6 +739,32 @@ REPL, then run `jspulse --alsa` in another terminal or use `jspulse --alsa &` to
738
739
  buninu-browser about:audio
739
740
  ```
740
741
 
742
+ ### Buninu Land (early-stage, experimental)
743
+
744
+ Run `buninu-land` inside `bunterm` (or another Kitty-graphics terminal) to
745
+ open the Wayland desktop. The compositor and Casty are bundled; startup
746
+ requires no npm download.
747
+
748
+ ```sh
749
+ buninu-land
750
+ ```
751
+
752
+ The socket directory defaults to `$TMPDIR`, or the system temporary directory
753
+ if `TMPDIR` is unset; an explicitly set `XDG_RUNTIME_DIR` takes precedence.
754
+ No environment setup is needed to launch the compositor.
755
+
756
+ In another terminal, set the Wayland client's `XDG_RUNTIME_DIR` to that socket
757
+ directory and `WAYLAND_DISPLAY` to the compositor's socket name (normally `wayland-buninu`;
758
+ additional instances use `wayland-buninu1`, `wayland-buninu2`, and so on),
759
+ then start a Wayland client. Clients inside a chroot can share the socket
760
+ with `chroot --shared-tmp` when the host's `TMPDIR` points to that directory.
761
+
762
+ The `/bin/buninu-land` launcher sets `CASTY_BROWSER` to
763
+ `/usr/lib/node_modules/buninu-land/compositor.js` and runs the bundled Casty.
764
+ `--casty` forwards arguments to Casty; `--headless`, `--help`, `--version`,
765
+ README options and reserved native frontends go through the package entry.
766
+ The package's CanvasKit WASM and license files are retained unchanged.
767
+
741
768
  ### Archive tools and other examples
742
769
 
743
770
  The small `tar` reads the archive's own headers for links and listing, so
@@ -872,9 +899,45 @@ apk add libgcc libstdc++
872
899
  # and /etc/resolv.conf are mounted for you and removed again on exit
873
900
  chroot /mnt
874
901
  chroot /mnt /bin/bun --version
875
- chroot -n /mnt /bin/sh # traditional: mount nothing
902
+ chroot -n /mnt /bin/sh # skip automatic mounts (explicit -b still applies)
876
903
  ```
877
904
 
905
+
906
+ Share host files and directories with repeatable `-b` options, placed before
907
+ the root filesystem path:
908
+
909
+ ```sh
910
+ # Mount a host directory at a different path inside the guest
911
+ chroot -b /home/user/project:/work /mnt /bin/sh
912
+
913
+ # Omit the guest path to expose the same absolute path inside the guest
914
+ chroot -b /home/user/project /mnt /bin/sh
915
+
916
+ # Share multiple paths, including individual files
917
+ chroot -b /home/user/project:/work -b /etc/hosts:/etc/hosts /mnt /bin/sh
918
+
919
+ # Share the host temporary directory, including Wayland sockets
920
+ export TMPDIR=/tmp
921
+ chroot --shared-tmp /mnt /bin/sh
922
+ # Equivalent to:
923
+ chroot -b "$TMPDIR:/tmp" /mnt /bin/sh
924
+ ```
925
+
926
+ `--shared-tmp` expands to `-b "$TMPDIR:/tmp"` and replaces the automatic
927
+ `/tmp` tmpfs. `TMPDIR` must be set and its source path must exist. The option
928
+ does not rewrite environment variables inside the guest; set `TMPDIR=/tmp`
929
+ there if the host uses a different path. For Buninu Land clients, set the
930
+ guest's `XDG_RUNTIME_DIR=/tmp` and `WAYLAND_DISPLAY` to the compositor's socket
931
+ name, and ensure the host's `TMPDIR` is the directory containing that socket.
932
+
933
+ Host paths may be relative; guest paths must be absolute. Missing guest
934
+ mount destinations are created. Directories are bound recursively, and a
935
+ repeated destination uses the last argument. Bind failures abort setup and
936
+ clean up its mounts, even with `-k`. Normally mounts are removed when the
937
+ command exits; `-k` / `--keep-mounts` keeps them after a successful setup.
938
+ Use `-v` to report mounts and unmounts. Full details: `chroot --help` or the
939
+ [chroot manual](initramfs/usr/share/doc/buninu-linux/chroot.md).
940
+
878
941
  ## Environment and dependencies
879
942
 
880
943
  The default JS build uses host Bun. The native build-tool dependencies below
@@ -1101,7 +1164,7 @@ index.js entry point: -f / -b / -r / --arch / --docker / --expo
1101
1164
  Dockerfile Debian-based oven/bun:1.4.2 plus the shell build toolchain
1102
1165
  fetch-alpine.sh [fetch] kernel, EFI stub, musl, network/input/display modules
1103
1166
  fetch-bun.sh [fetch] Bun, libstdc++, libgcc
1104
- fetch-npm.sh [fetch] pinned browser, casty, jspulse and jspinyin tarballs (download only)
1167
+ fetch-npm.sh [fetch] pinned browser, land, casty, jspulse and jspinyin tarballs (download only)
1105
1168
  build-uki.sh [build] UKI; calls scripts/pack-initramfs.sh
1106
1169
  build-image.sh [build] GPT disk image; shared with the hello-world EFI
1107
1170
  run-qemu.sh [run] QEMU q35 (x86_64) or virt (aarch64), KVM/HVF/TCG
@@ -1129,11 +1192,12 @@ tampered with is fetched again and has to pass the same hash before a script
1129
1192
  extracts from it. Bumping a version changes both the file name and the hash, so
1130
1193
  it always refetches.
1131
1194
 
1132
- The four application packages are pinned separately in
1133
- `scripts/npm-packages.md`: `buninu-browser@0.2.3`,
1134
- `@drxiaozhi/casty@1.3.4`, `@drxiaozhi/jspulse@0.2.1`, and
1195
+ The five application packages are pinned separately in
1196
+ `scripts/npm-packages.md`: `buninu-browser@0.2.3`, `buninu-land@0.1.2`,
1197
+ `@drxiaozhi/casty@1.3.6`, `@drxiaozhi/jspulse@0.2.1`, and
1135
1198
  `@drxiaozhi/jspinyin@0.1.1`. These were npm's `latest` versions when checked
1136
- on 2026-10-11, with buninu-browser updated to npm's `latest` on 2026-10-12;
1199
+ on 2026-10-11, with buninu-browser updated on 2026-10-12, and
1200
+ casty 1.3.6 and buninu-land 0.1.2 verified on 2026-10-16;
1137
1201
  fetch never resolves `latest` or a version range at runtime.
1138
1202
  Each registry tarball was checked against its published SHA-512 integrity
1139
1203
  before its SHA-256 was recorded in the shared pins file.
@@ -1159,7 +1223,7 @@ dependency resolution run. JS extraction uses the initramfs JS `tar` and
1159
1223
  requires a host Bun; shell/Docker extraction uses the host `tar`, without Bun.
1160
1224
  The source `initramfs/` and project `package.json` are not modified by packing.
1161
1225
 
1162
- The image gets `/usr/lib/package.json` with the four exact dependencies and
1226
+ The image gets `/usr/lib/package.json` with the five exact dependencies and
1163
1227
  standard `node_modules/.bin` links. This reserves a normal package-manager
1164
1228
  layout for later `cd /usr/lib` and `bun i --backend=copyfile`; no lockfile is
1165
1229
  fabricated. `bun i` alone retains the pinned versions, not newer releases.
@@ -1556,6 +1620,7 @@ for the userspace session.
1556
1620
  * [Playing YouTube with sound (experimental)](#playing-youtube-with-sound-experimental)
1557
1621
  - [Commands inside /bin](#commands-inside-bin)
1558
1622
  * [Buninu Browser (early-stage, experimental)](#buninu-browser-early-stage-experimental)
1623
+ * [Buninu Land (early-stage, experimental)](#buninu-land-early-stage-experimental)
1559
1624
  * [Archive tools and other examples](#archive-tools-and-other-examples)
1560
1625
  - [Optional chroot and native binaries](#optional-chroot-and-native-binaries)
1561
1626
  * [Debian and Chromium with casty](#debian-and-chromium-with-casty)
@@ -1609,8 +1674,8 @@ architecture under `native/<arch>/lib/`, and the built image redistributes
1609
1674
  several more. Each stays
1610
1675
  under its own terms, with platform license texts in [`LICENSES/`](LICENSES/).
1611
1676
 
1612
- The build also fetches four SHA-256-pinned npm releases and redistributes
1613
- them under `/usr/lib/node_modules`: `buninu-browser`, `@drxiaozhi/casty`,
1677
+ The build also fetches five SHA-256-pinned npm releases and redistributes
1678
+ them under `/usr/lib/node_modules`: `buninu-browser`, `buninu-land`, `@drxiaozhi/casty`,
1614
1679
  `@drxiaozhi/jspulse` and `@drxiaozhi/jspinyin`. Their license and notice files
1615
1680
  remain inside their package directories. Exact download URLs and hashes are
1616
1681
  in [`scripts/npm-packages.md`](scripts/npm-packages.md); versions, upstream
@@ -1630,9 +1695,10 @@ when redistributing the image.
1630
1695
  | Linux kernels and networking, storage, xHCI, USB and HID modules | GPL-2.0-only with the Linux syscall note | image only |
1631
1696
  | systemd EFI stub | LGPL-2.1-or-later | image only |
1632
1697
  | Bun | MIT, plus the licenses of what it statically links | image only |
1633
- | buninu-browser 0.2.2 | MIT for its own code; bundled components have their own licenses | npm download → image |
1698
+ | buninu-browser 0.2.3 | MIT for its own code; bundled components have their own licenses | npm download → image |
1699
+ | buninu-land 0.1.2 | MIT for its own code; bundled components retain their own licenses and notices | npm download → image |
1634
1700
  | wasmpeg / FFmpeg (`@wasmpeg/cpu` 1.0.1, bundled in buninu-browser) | LGPL-2.1-or-later, plus component notices; source archive and rebuild documentation retained | browser package → image |
1635
- | `@drxiaozhi/casty` 1.3.4 | MIT | npm download → image |
1701
+ | `@drxiaozhi/casty` 1.3.6 | MIT | npm download → image |
1636
1702
  | `@drxiaozhi/jspulse` 0.2.1 | LGPL-2.1-or-later, plus component notices | npm download → image |
1637
1703
  | `@drxiaozhi/jspinyin` 0.1.1 | MIT; hanzipinyin material under the Unlicense | npm download → image |
1638
1704
  | CanvasKit 0.41.1 (`initramfs/lib/canvaskit/`, Skia compiled to WebAssembly) | BSD-3-Clause (`LICENSES/BSD-3-Clause-Skia.txt`) | committed |
@@ -0,0 +1,26 @@
1
+ #!/bin/bun
2
+ // Keep the offline casty launcher outside the npm package so package updates
3
+ // preserve the distribution entry point.
4
+ import { resolve, join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ const root = resolve(import.meta.dir, "..");
8
+ const modules = join(root, "usr/lib/node_modules");
9
+ const browser = join(modules, "buninu-land");
10
+ const entry = join(browser, "index.js");
11
+ const { chooseFrontend } = await import(pathToFileURL(entry).href);
12
+ const args = process.argv.slice(2);
13
+ const choice = chooseFrontend(args);
14
+ const casty = choice.frontend === "casty";
15
+ const forwarded = casty ? choice.args : args;
16
+ // Bun consumes a leading separator after the script path; preserve Casty's
17
+ // own separator when --casty is followed directly by -- engine flags.
18
+ const runtimeArgs = forwarded[0] === "--" ? ["--", ...forwarded] : forwarded;
19
+ const child = Bun.spawn({
20
+ cmd: [process.execPath, casty ? join(modules, "@drxiaozhi/casty/index.js") : entry,
21
+ ...runtimeArgs],
22
+ env: casty ? { ...process.env, CASTY_BROWSER: join(browser, "compositor.js") } : process.env,
23
+ stdio: ["inherit", "inherit", "inherit"],
24
+ });
25
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
26
+ process.exit(await child.exited);
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { chmodSync, existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
13
+ import { dirname } from "node:path";
14
+ import { parseChrootArguments, resolveBindings, resolveGuestPath } from "../lib/chroot-options.js";
13
15
  import { showDocument } from "../lib/document.js";
14
16
 
15
17
  const MS = { RDONLY: 1n, NOSUID: 2n, NODEV: 4n, NOEXEC: 8n, BIND: 4096n, REC: 16384n, SLAVE: 1n << 19n };
@@ -23,27 +25,18 @@ const fail = (message, status = 125) => {
23
25
  const usage = () => {
24
26
  console.error(`Usage:
25
27
  chroot NEWROOT [COMMAND [ARGUMENT...]]
26
- chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
28
+ chroot [-n] [-k] [-v] [-b HOST[:GUEST]] [--shared-tmp] NEWROOT [COMMAND [ARGUMENT...]]
27
29
  chroot -h | --help full manual (chroot.md)`);
28
30
  process.exit(125);
29
31
  };
30
32
 
31
33
  // ------------------------------------------------------------- arguments --
32
34
 
33
- const options = { mount: true, keep: false, verbose: false };
34
- const positional = [];
35
- const argv = process.argv.slice(2);
36
- for (let index = 0; index < argv.length; index++) {
37
- const argument = argv[index];
38
- if (positional.length > 0) { positional.push(argument); continue; }
39
- if (argument === "--") { positional.push(...argv.slice(index + 1)); break; }
40
- if (argument === "-h" || argument === "--help") { await showDocument("chroot"); process.exit(0); }
41
- else if (argument === "-n" || argument === "--no-mount") options.mount = false;
42
- else if (argument === "-k" || argument === "--keep-mounts") options.keep = true;
43
- else if (argument === "-v" || argument === "--verbose") options.verbose = true;
44
- else if (argument.startsWith("-") && argument.length > 1) { console.error(`chroot: unknown option ${argument}`); usage(); }
45
- else positional.push(argument);
46
- }
35
+ let parsed;
36
+ try { parsed = parseChrootArguments(process.argv.slice(2)); }
37
+ catch (error) { fail(error.message); }
38
+ const { options, positional } = parsed;
39
+ if (parsed.help) { await showDocument("chroot"); process.exit(0); }
47
40
  if (positional.length === 0) usage();
48
41
 
49
42
  const [target, ...command] = positional;
@@ -55,6 +48,10 @@ try {
55
48
  fail(`cannot change root directory to ${target}: ${error.code === "ENOENT" ? "no such directory" : error.message}`);
56
49
  }
57
50
  if (root === "/") fail("refusing to chroot to /");
51
+ let bindings;
52
+ try { bindings = resolveBindings(options.binds); }
53
+ catch (error) { fail(`invalid bind: ${error.message}`); }
54
+
58
55
 
59
56
  const report = (message) => { if (options.verbose) console.error(`chroot: ${message}`); };
60
57
 
@@ -80,57 +77,39 @@ const made = [];
80
77
  // Prepares one mount. `inside` is the path within the new root. A target that
81
78
  // is already a mount point, or that cannot be created on a read-only root, is
82
79
  // left alone rather than treated as a failure: the command may still run.
83
- const prepare = ({ inside, source, type, flags = 0n, data, file = false }) => {
80
+ const prepare = ({ inside, source, type, flags = 0n, data, file = false, required = false }) => {
81
+ inside = resolveGuestPath(root, inside);
82
+ if (!required && bindings.some(bind => {
83
+ const destination = resolveGuestPath(root, bind.inside);
84
+ return inside === destination || inside.startsWith(destination + "/");
85
+ })) return;
86
+ if (required && made.includes(inside)) throw Error(`bind destination is already used by this invocation: ${inside}`);
84
87
  const point = `${root}${inside}`;
85
- if (mountPoints().includes(point)) {
88
+ if (!required && mountPoints().includes(point)) {
86
89
  report(`${inside} is already mounted`);
87
90
  return;
88
91
  }
89
92
  if (!existsSync(point)) {
90
93
  try {
91
- if (file) writeFileSync(point, "");
94
+ if (file) { mkdirSync(dirname(point), { recursive: true, mode: 0o755 }); writeFileSync(point, "", { flag: "wx" }); }
92
95
  else mkdirSync(point, { recursive: true, mode: 0o755 });
93
96
  } catch (error) {
97
+ if (required) throw Error(`cannot create ${inside}: ${error.message}`);
94
98
  report(`cannot create ${inside}: ${error.message}; skipped`);
95
99
  return;
96
100
  }
97
101
  }
98
102
  if (mountSyscall(source, point, type, flags, data) === -1) {
103
+ if (required) throw Error(`bind ${source} -> ${inside}: ${strerror(errno())}`);
99
104
  report(`mount ${inside}: ${strerror(errno())}; skipped`);
100
105
  return;
101
106
  }
102
107
  made.push(inside);
103
108
  report(`mounted ${inside}`);
104
- };
105
-
106
- if (options.mount) {
107
- prepare({ inside: "/proc", source: "proc", type: "proc", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
108
- prepare({ inside: "/sys", source: "sysfs", type: "sysfs", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
109
- // /dev is bound rather than mounted fresh so the devices, the pty
110
- // multiplexer and anything the host added are all there. The bind is then
111
- // made a slave: mounts appearing inside do not propagate back to the host,
112
- // and unmounting it later does not take the host's /dev with it.
113
- prepare({ inside: "/dev", source: "/dev", type: "none", flags: MS.BIND | MS.REC });
114
- if (made.includes("/dev")) {
115
- if (mountSyscall("none", `${root}/dev`, "none", MS.REC | MS.SLAVE) === -1) {
116
- report(`making /dev a slave mount: ${strerror(errno())}`);
117
- }
118
- // Only needed when the host has no /dev/pts of its own to bring along.
119
- if (!existsSync(`${root}/dev/pts/ptmx`)) {
120
- prepare({ inside: "/dev/pts", source: "devpts", type: "devpts", flags: MS.NOSUID | MS.NOEXEC, data: "gid=5,mode=620,ptmxmode=666" });
121
- }
109
+ if (required && !file && mountSyscall("none", point, "none", MS.REC | MS.SLAVE) === -1) {
110
+ throw Error(`making ${inside} a slave mount: ${strerror(errno())}`);
122
111
  }
123
- prepare({ inside: "/run", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=755" });
124
- prepare({ inside: "/tmp", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=1777" });
125
- // A bound file, so name resolution inside follows the host's configuration
126
- // and nothing inside the root is modified.
127
- if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
128
-
129
- chmodSync(`/etc/resolv.conf`, 0o644);
130
-
131
- prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
132
- }
133
- }
112
+ };
134
113
 
135
114
  // Mount points are recorded by their path inside the new root. Until
136
115
  // chroot(2) has happened they are reached through this prefix; afterwards
@@ -167,10 +146,52 @@ const cleanup = () => {
167
146
 
168
147
  process.on("exit", cleanup);
169
148
 
149
+ try {
150
+ if (options.mount) {
151
+ prepare({ inside: "/proc", source: "proc", type: "proc", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
152
+ prepare({ inside: "/sys", source: "sysfs", type: "sysfs", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
153
+ // /dev is bound rather than mounted fresh so the devices, the pty
154
+ // multiplexer and anything the host added are all there. The bind is then
155
+ // made a slave: mounts appearing inside do not propagate back to the host,
156
+ // and unmounting it later does not take the host's /dev with it.
157
+ prepare({ inside: "/dev", source: "/dev", type: "none", flags: MS.BIND | MS.REC });
158
+ if (made.includes("/dev")) {
159
+ if (mountSyscall("none", `${root}/dev`, "none", MS.REC | MS.SLAVE) === -1) {
160
+ report(`making /dev a slave mount: ${strerror(errno())}`);
161
+ }
162
+ // Only needed when the host has no /dev/pts of its own to bring along.
163
+ if (!existsSync(`${root}/dev/pts/ptmx`)) {
164
+ prepare({ inside: "/dev/pts", source: "devpts", type: "devpts", flags: MS.NOSUID | MS.NOEXEC, data: "gid=5,mode=620,ptmxmode=666" });
165
+ }
166
+ }
167
+ prepare({ inside: "/run", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=755" });
168
+ prepare({ inside: "/tmp", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=1777" });
169
+ // A bound file, so name resolution inside follows the host's configuration
170
+ // and nothing inside the root is modified.
171
+ if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
172
+
173
+ chmodSync(`/etc/resolv.conf`, 0o644);
174
+
175
+ prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
176
+ }
177
+ }
178
+
179
+ for (const binding of bindings) {
180
+ prepare({ ...binding, type: "none", flags: MS.BIND | (binding.file ? 0n : MS.REC), required: true });
181
+ }
182
+ } catch (error) {
183
+ // --keep-mounts applies to a prepared session, not a failed setup.
184
+ options.keep = false;
185
+ cleanup();
186
+ fail(error.message);
187
+ }
188
+
189
+
170
190
  // ----------------------------------------------------------------- enter --
171
191
 
172
192
  if (libc.symbols.chroot(ptr(cString(root))) === -1) {
173
193
  const code = errno();
194
+ options.keep = false;
174
195
  cleanup();
175
196
  fail(`cannot change root directory to ${target}: ${code === Errno.EPERM ? "must be superuser" : strerror(code)}`);
176
197
  }
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.21 - 2026-10-15
4
+
5
+ - Sync jsgotty to 1.1.16: Turn on pixel report
6
+
7
+ ## 0.4.20 - 2026-10-13
8
+
9
+ - Sync bunmsh to 0.4.0:
10
+ * Add a pure JavaScript awk fallback, file diff with line alignment, cmp,
11
+ uniq, expr, and egrep/fgrep aliases; extend sed and other utility builtins
12
+ for configure scripts and compiler builds.
13
+ * Improve compound commands, here-documents, parameter expansion, function
14
+ and loop control, descriptor redirection, and pipeline/subshell state
15
+ transfer for Autoconf and GCC bootstrap workloads.
16
+ * In `set -o posix` mode, use plain ls output and pass only exported variables
17
+ and command-prefix assignments to external programs. Default mode retains
18
+ ls icons and its existing environment behavior.
19
+ * Preserve the existing background-process behavior and stable
20
+ `Bun.sha.procs` indices. POSIX and awk compatibility remain partial; see
21
+ the bundled bunmsh PORTING.md for limitations.
22
+ - Sync jsgotty to 1.1.15: forward Kitty graphics to the terminal in `--client`
23
+ mode and avoid applying the corresponding cursor movement twice.
24
+
3
25
  ## 0.4.19 - 2026-10-12
4
26
 
5
27
  - Sync bunmsh to 0.3.11: more POSIX syntax
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "jsgotty",
9
- "version": "1.1.15",
9
+ "version": "1.1.16",
10
10
  "dependencies": {
11
11
  "node-addon-api": "^7.1.1",
12
12
  "ws": "^8.18.3"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -12530,6 +12530,7 @@
12530
12530
  (this.fontSizeOverride = fontSizeValue),
12531
12531
  (this.term = new r.Terminal({
12532
12532
  allowProposedApi: !0,
12533
+ windowOptions: { getWinSizePixels: true, getCellSizePixels: true },
12533
12534
  customGlyphs: !0,
12534
12535
  rescaleOverlappingGlyphs: !0,
12535
12536
  fontFamily:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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",
@@ -0,0 +1,15 @@
1
+ // Report the content area in the same pixel units as SGR 1016, not fb margins.
2
+ export function attachGeometry({ term, renderer, reply }) {
3
+ term.options.windowOptions = { ...term.options.windowOptions, getWinSizePixels: true, getCellSizePixels: true };
4
+ return term.parser.registerCsiHandler({ final: 't' }, params => {
5
+ if (params[0] === 14 && params[1] !== 2) {
6
+ reply(`\x1b[4;${Math.round(term.rows * renderer.cellHeight)};${Math.round(term.cols * renderer.cellWidth)}t`);
7
+ return true;
8
+ }
9
+ if (params[0] === 16) {
10
+ reply(`\x1b[6;${Math.round(renderer.cellHeight)};${Math.round(renderer.cellWidth)}t`);
11
+ return true;
12
+ }
13
+ return false;
14
+ });
15
+ }
@@ -0,0 +1,10 @@
1
+ // Supply both coordinate systems: xterm selects cells (1006) or pixels (1016).
2
+ // Pixels are relative to the terminal content, excluding framebuffer margins.
3
+ export function terminalMouseEvent(report, renderer, button, action) {
4
+ const x = Math.floor(report.x - renderer.offsetX);
5
+ const y = Math.floor(report.y - renderer.offsetY);
6
+ return {
7
+ col: Math.floor(x / renderer.cellWidth), row: Math.floor(y / renderer.cellHeight),
8
+ x, y, button, action, ctrl: false, alt: false, shift: false,
9
+ };
10
+ }
@@ -21,6 +21,8 @@ import { Renderer, defaultTheme } from "./renderer.js";
21
21
  import { ImageStore } from "./images.js";
22
22
  import { openKeyboard, translateKeys } from "./input.js";
23
23
  import { claimVirtualConsole } from "./vt.js";
24
+ import { attachGeometry } from "./geometry.js";
25
+ import { terminalMouseEvent } from "./pointer-report.js";
24
26
  import { attachClipboard } from "./clipboard.js";
25
27
 
26
28
  const require = createRequire(import.meta.url);
@@ -160,6 +162,7 @@ export const createSession = ({
160
162
  });
161
163
  backend.onData(pump);
162
164
  term.onData((data) => backend.write(Buffer.from(data, "utf8")));
165
+ attachGeometry({ term, renderer, reply: text => backend.write(Buffer.from(text, "utf8")) });
163
166
  // OSC 52 copy and paste go straight to xclip; replies return to the program.
164
167
  // Without it (--no-clipboard) xterm.js ignores OSC 52 and nothing reaches xclip.
165
168
  if (clipboard) attachClipboard({ term, reply: (text) => backend.write(Buffer.from(text, "utf8")), onError });
@@ -181,7 +184,7 @@ export const createSession = ({
181
184
  // Turns a pointer report from mouse.js into the mouse sequences the
182
185
  // program asked for. xterm.js's CoreMouseService owns the protocol: it
183
186
  // knows which events the program enabled (DECSET 1000/1002/1003), encodes
184
- // them (SGR 1006 or the default form) and sends them through onData, so
187
+ // them (SGR 1006, pixel 1016, or the default form) and sends them through onData, so
185
188
  // nothing here builds an escape sequence. Reports arrive only while a
186
189
  // pointer is open, and this is the only place the terminal sees one.
187
190
  const CoreMouseButton = { LEFT: 0, MIDDLE: 1, RIGHT: 2, NONE: 3, WHEEL: 4 };
@@ -223,11 +226,9 @@ export const createSession = ({
223
226
  if (report.wheel) wheelScroll(report.wheel);
224
227
  return;
225
228
  }
226
- const column = Math.floor((report.x - renderer.offsetX) / renderer.cellWidth);
227
- const row = Math.floor((report.y - renderer.offsetY) / renderer.cellHeight);
228
229
  const send = (button, action) => {
229
230
  try {
230
- service.triggerMouseEvent({ col: column, row, button, action, ctrl: false, alt: false, shift: false });
231
+ service.triggerMouseEvent(terminalMouseEvent(report, renderer, button, action));
231
232
  } catch (error) {
232
233
  onError(error);
233
234
  }
@@ -0,0 +1,69 @@
1
+ import { realpathSync, statSync, lstatSync, readlinkSync } from 'node:fs';
2
+ import { posix, resolve } from 'node:path';
3
+
4
+ export function parseChrootArguments(argv, env = process.env) {
5
+ const options = { mount: true, keep: false, verbose: false, binds: [] };
6
+ let positional = [];
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const arg = argv[i];
9
+ if (arg === '--') { positional = argv.slice(i + 1); break; }
10
+ if (arg === '-h' || arg === '--help') return { options, positional, help: true };
11
+ if (arg === '-n' || arg === '--no-mount') options.mount = false;
12
+ else if (arg === '-k' || arg === '--keep-mounts') options.keep = true;
13
+ else if (arg === '-v' || arg === '--verbose') options.verbose = true;
14
+ else if (arg === '-b' || arg === '--bind') {
15
+ if (++i >= argv.length || !argv[i] || argv[i].startsWith('-')) throw Error(`${arg} requires HOST[:GUEST]`);
16
+ options.binds.push(argv[i]);
17
+ } else if (arg.startsWith('--bind=')) {
18
+ if (!arg.slice(7)) throw Error('--bind requires HOST[:GUEST]');
19
+ options.binds.push(arg.slice(7));
20
+ } else if (arg === '--shared-tmp') {
21
+ if (!env.TMPDIR) throw Error('--shared-tmp requires TMPDIR to be set');
22
+ options.binds.push(`${env.TMPDIR}:/tmp`);
23
+ } else if (arg.startsWith('-') && arg.length > 1) throw Error(`unknown option ${arg}`);
24
+ else { positional = argv.slice(i); break; }
25
+ }
26
+ return { options, positional, help: false };
27
+ }
28
+
29
+ export function resolveBindings(specs, cwd = process.cwd()) {
30
+ const bindings = new Map();
31
+ for (const spec of specs) {
32
+ const colon = spec.indexOf(':');
33
+ const host = colon < 0 ? spec : spec.slice(0, colon);
34
+ const guest = colon < 0 ? resolve(cwd, host) : spec.slice(colon + 1);
35
+ if (!host || !guest || host.includes('\0') || guest.includes('\0')) throw Error(`invalid bind: ${spec}`);
36
+ if (!posix.isAbsolute(guest)) throw Error(`bind guest path must be absolute: ${guest}`);
37
+ const inside = posix.normalize(guest);
38
+ if (inside === '/') throw Error('bind destination must not replace the chroot root');
39
+ const source = realpathSync(resolve(cwd, host));
40
+ bindings.set(inside, { inside, source, file: !statSync(source).isDirectory() });
41
+ }
42
+ // Parents must be mounted before children. For a repeated destination, the
43
+ // last argument wins, including --shared-tmp versus an explicit /tmp bind.
44
+ return [...bindings.values()].sort((a, b) => a.inside.split('/').length - b.inside.split('/').length);
45
+ }
46
+
47
+ // Resolve guest symlinks as chroot would: absolute links start at NEWROOT,
48
+ // never at the host's /. Missing suffixes are created by the mount caller.
49
+ export function resolveGuestPath(root, inside) {
50
+ const todo = inside.split('/'), parts = [];
51
+ let links = 0;
52
+ while (todo.length) {
53
+ const part = todo.shift();
54
+ if (!part || part === '.') continue;
55
+ if (part === '..') { parts.pop(); continue; }
56
+ const candidate = `${root}/${[...parts, part].join('/')}`;
57
+ let entry;
58
+ try { entry = lstatSync(candidate); }
59
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
60
+ if (entry?.isSymbolicLink()) {
61
+ if (++links > 40) throw Error(`too many symlinks in bind destination: ${inside}`);
62
+ const target = readlinkSync(candidate);
63
+ if (target.startsWith('/')) parts.length = 0;
64
+ todo.unshift(...target.split('/'));
65
+ } else parts.push(part);
66
+ }
67
+ if (!parts.length) throw Error('bind destination resolves to the chroot root');
68
+ return '/' + parts.join('/');
69
+ }
@@ -82,7 +82,10 @@ Every pointing device under `/dev/input/event*` is opened and an arrow is
82
82
  drawn on the screen. Clicks, drags and wheel notches reach the program as
83
83
  the xterm mouse sequences it asked for: nothing is sent unless the program
84
84
  enabled reporting (`DECSET 1000`, `1002` or `1003`), and what is sent follows
85
- the mode it chose, SGR (`1006`) included. `jsmdcui`, `micro`, `htop`, `vim`
85
+ the mode it chose, including SGR cell coordinates (`1006`) and SGR pixel
86
+ coordinates (`1016`). Mode 1016 also answers DECRQM queries; pixel positions
87
+ exclude the framebuffer margins and preserve movement within a cell.
88
+ CSI 14t/16t report the content and cell dimensions in those same pixel units. `jsmdcui`, `micro`, `htop`, `vim`
86
89
  and anything else that speaks the protocol work with no configuration.
87
90
 
88
91
  When the program is not reading the mouse, the wheel scrolls instead: back
@@ -11,7 +11,7 @@ every mount it made when the command exits.
11
11
 
12
12
  ```sh
13
13
  chroot NEWROOT [COMMAND [ARGUMENT...]]
14
- chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
14
+ chroot [-n] [-k] [-v] [-b HOST[:GUEST]] [--shared-tmp] NEWROOT [COMMAND [ARGUMENT...]]
15
15
  chroot -h | --help
16
16
  ```
17
17
 
@@ -30,7 +30,7 @@ With no COMMAND it runs `$SHELL` if that exists inside NEWROOT, otherwise
30
30
  | `/tmp` | `tmpfs` | scratch space that does not touch the root filesystem |
31
31
  | `/etc/resolv.conf` | bind of the host's file | name resolution, without editing anything inside |
32
32
 
33
- A mount point that already exists as a mount is left alone. A directory that
33
+ For automatic mounts, a destination that is already mounted is left alone. A directory that
34
34
  is missing is created; if the root filesystem is read-only and that fails,
35
35
  that one mount is skipped and the rest still happen — use `-v` to see which.
36
36
 
@@ -42,11 +42,30 @@ host's `/dev` with it.
42
42
 
43
43
  | Option | Meaning |
44
44
  |---|---|
45
- | `-n`, `--no-mount` | Do not mount anything: the behaviour of the traditional `chroot`. Note that Bun itself cannot start without `/proc` and `/dev/urandom`, so `-n` suits statically linked programs. |
45
+ | `-n`, `--no-mount` | Skip automatic system mounts; explicit `-b` mounts still apply. Without binds, this is the behaviour of the traditional `chroot`. Note that Bun itself cannot start without `/proc` and `/dev/urandom`, so `-n` suits statically linked programs. |
46
+ | `-b HOST[:GUEST]`, `--bind HOST[:GUEST]` | Bind a host directory or file into the new root; repeat for multiple paths. Without GUEST, use HOST’s absolute path inside the guest. |
47
+ | `--shared-tmp` | Equivalent to `-b "$TMPDIR:/tmp"`; TMPDIR must be set and exist. Replaces the automatic `/tmp` tmpfs. |
46
48
  | `-k`, `--keep-mounts` | Leave the mounts in place after the command exits, to enter the same root again cheaply. Clean up later with `umount -R`. |
47
49
  | `-v`, `--verbose` | Report every mount and unmount. |
48
50
  | `--` | End of options; everything after it is NEWROOT and COMMAND. |
49
51
 
52
+ Bind options must appear before NEWROOT. Host paths may be relative to the
53
+ current directory; guest paths must be absolute. Missing destination directories
54
+ and file placeholders are created. Guest symlinks are resolved relative to
55
+ NEWROOT, including absolute symlinks. Binding over the guest root `/` is rejected.
56
+
57
+ Explicit binds replace automatic mounts at or below their destination. Directories
58
+ are bound recursively and made slave mounts, like `/dev`; files use a plain bind.
59
+ A repeated destination uses the last argument, so a later `-b HOST:/tmp` can
60
+ override `--shared-tmp` (and vice versa). Parent binds are applied before children.
61
+ Explicit bind failures abort with status 125 and clean up this invocation's mounts,
62
+ even with `-k`. Existing mounts may be covered by a new explicit bind and are
63
+ revealed again when it is removed. Source data is shared, not copied.
64
+
65
+ `--shared-tmp` only adds the bind; it does not rewrite environment variables.
66
+ If the host's TMPDIR path is different from `/tmp`, applications in the guest may
67
+ need `TMPDIR=/tmp` in their environment.
68
+
50
69
  ## Examples
51
70
 
52
71
  ```sh
@@ -63,6 +82,21 @@ chroot -v /mnt /bin/sh -c 'ls /proc | head -3'
63
82
  # Traditional behaviour: nothing is mounted for you
64
83
  chroot -n /mnt /bin/sh
65
84
 
85
+ # Share a host directory at a different guest path
86
+ chroot -b /home/user/project:/work /mnt /bin/sh
87
+
88
+ # Share the same absolute path inside the guest
89
+ chroot -b /home/user/project /mnt /bin/sh
90
+
91
+ # Share files, or multiple paths
92
+ chroot -b /home/user/project:/work -b /etc/hosts:/etc/hosts /mnt /bin/sh
93
+
94
+ # Share the host temporary directory (including Unix sockets)
95
+ export TMPDIR=/tmp
96
+ chroot --shared-tmp /mnt /bin/sh
97
+ # Equivalent explicit bind:
98
+ chroot -b "$TMPDIR:/tmp" /mnt /bin/sh
99
+
66
100
  # Keep the mounts for repeated visits
67
101
  chroot -k /mnt /bin/sh -c 'true'
68
102
  chroot -k /mnt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-linux",
3
- "version": "0.10.56",
3
+ "version": "0.11.56",
4
4
  "description": "A Linux distribution running Bun as PID 1 with the Buninu userspace.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -1,14 +1,19 @@
1
1
  # npm packages
2
2
  # Latest versions checked on 2026-10-11; SHA-256 pins verified against registry SHA-512 integrity.
3
3
  # buninu-browser updated and verified on 2026-10-12.
4
+ # casty 1.3.6 and buninu-land 0.1.2 verified on 2026-10-16.
4
5
 
5
6
  "buninu-browser@0.2.3":
6
7
  - https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.2.3.tgz
7
8
  - sha256:0f7c9cd9df689d7b0900fdf3172851125ebd8aba12bed555be7e091d427146e8
8
9
 
9
- "@drxiaozhi/casty@1.3.4":
10
- - https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.3.4.tgz
11
- - sha256:1899179d173702721c2f6e8bbffab51ca3421e899ac59c58b2588cfc342fa0df
10
+ "buninu-land@0.1.2":
11
+ - https://registry.npmjs.org/buninu-land/-/buninu-land-0.1.2.tgz
12
+ - sha256:0b682e1d57f953d9fa9415369c320e4e35bf7575c2859673c26a0789f5369490
13
+
14
+ "@drxiaozhi/casty@1.3.6":
15
+ - https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.3.6.tgz
16
+ - sha256:2b5f84e3a63c2c16fa92dc88faa43eec54edc2fe69154e5270af3cd9e7f472b8
12
17
 
13
18
  "@drxiaozhi/jspulse@0.2.1":
14
19
  - https://registry.npmjs.org/@drxiaozhi/jspulse/-/jspulse-0.2.1.tgz
@@ -0,0 +1,27 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {Terminal} from '../initramfs/lib/xterm/xterm-headless.mjs';
3
+ import {terminalMouseEvent} from '../initramfs/lib/bunterm/pointer-report.js';
4
+ test('bunterm negotiates 1016 and emits real pixel press, release and same-cell motion',async()=>{
5
+ const term=new Terminal({cols:80,rows:24,allowProposedApi:true}),out=[];term.onData(s=>out.push(s));
6
+ const write=s=>new Promise(r=>term.write(s,r));
7
+ const renderer={offsetX:5,offsetY:7,cellWidth:10,cellHeight:20};
8
+ const send=(x,y,action)=>term._core.coreMouseService.triggerMouseEvent(terminalMouseEvent({x,y},renderer,0,action));
9
+ try{
10
+ await write('\x1b[?1016$p');expect(out.pop()).toBe('\x1b[?1016;2$y');
11
+ await write('\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1016$p');expect(out.pop()).toBe('\x1b[?1016;1$y');
12
+ send(30,68,1);expect(out.pop()).toBe('\x1b[<0;25;61M');
13
+ send(30,68,0);expect(out.pop()).toBe('\x1b[<0;25;61m');
14
+ send(31,68,32);expect(out.pop()).toBe('\x1b[<32;26;61M');
15
+ send(32,68,32);expect(out.pop()).toBe('\x1b[<32;27;61M');
16
+ await write('\x1b[?1016l\x1b[?1006h');send(30,68,1);expect(out.pop()).toBe('\x1b[<0;3;4M');
17
+ }finally{term.dispose();}
18
+ });
19
+
20
+ test('geometry queries use terminal content pixels and exclude framebuffer margins',async()=>{
21
+ const {attachGeometry}=await import('../initramfs/lib/bunterm/geometry.js');
22
+ const term=new Terminal({cols:80,rows:24,allowProposedApi:true}),out=[];
23
+ const renderer={offsetX:5,offsetY:7,cellWidth:10,cellHeight:20};
24
+ const handler=attachGeometry({term,renderer,reply:s=>out.push(s)});
25
+ try{await new Promise(r=>term.write('\x1b[14t\x1b[16t',r));expect(out).toEqual(['\x1b[4;480;800t','\x1b[6;20;10t']);}
26
+ finally{handler.dispose();term.dispose();}
27
+ });
@@ -0,0 +1,70 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,symlinkSync,rmSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {parseChrootArguments,resolveBindings,resolveGuestPath} from '../initramfs/lib/chroot-options.js';
6
+
7
+ test('chroot bind arguments expand shared tmp, repeat, and stop at NEWROOT',()=>{
8
+ const result=parseChrootArguments(['-n','-b','/host:/guest','--shared-tmp','--bind=/same','/root','echo','-b','unchanged'],{TMPDIR:'/host temp'});
9
+ expect(result.options).toMatchObject({mount:false,binds:['/host:/guest','/host temp:/tmp','/same']});
10
+ expect(result.positional).toEqual(['/root','echo','-b','unchanged']);
11
+ expect(()=>parseChrootArguments(['--shared-tmp','/root'],{})).toThrow('TMPDIR');
12
+ expect(()=>parseChrootArguments(['-b'])).toThrow('requires');
13
+ expect(()=>parseChrootArguments(['--bind='])).toThrow('requires');
14
+ expect(parseChrootArguments(['--','-root','sh']).positional).toEqual(['-root','sh']);
15
+ });
16
+
17
+ test('bind resolution preserves same-path destinations and confines guest symlinks',()=>{
18
+ const dir=mkdtempSync(join(tmpdir(),'buninu-bind-path-'));
19
+ try{
20
+ mkdirSync(join(dir,'root'));mkdirSync(join(dir,'host'));writeFileSync(join(dir,'file'),'data');
21
+ const binds=resolveBindings(['host','file:/etc/config','host:/tmp','file:/tmp'],dir);
22
+ expect(binds.find(b=>b.inside===join(dir,'host'))?.source).toBe(join(dir,'host'));
23
+ expect(binds.find(b=>b.inside==='/tmp')?.file).toBe(true);
24
+ expect(()=>resolveBindings(['host:relative'],dir)).toThrow('absolute');
25
+ expect(()=>resolveBindings(['host:/'],dir)).toThrow('root');
26
+ expect(()=>resolveBindings(['missing:/ok'],dir)).toThrow();
27
+ symlinkSync('/run/tmp',join(dir,'root/tmp'));
28
+ expect(resolveGuestPath(join(dir,'root'),'/tmp')).toBe('/run/tmp');
29
+ symlinkSync('../../outside',join(dir,'root/escape'));
30
+ expect(resolveGuestPath(join(dir,'root'),'/escape')).toBe('/outside');
31
+ }finally{rmSync(dir,{recursive:true,force:true});}
32
+ });
33
+
34
+ // Execute the actual CLI with recorded syscall bindings. No host mounts or
35
+ // chroot occur; this also exercises failure cleanup and the -n interaction.
36
+ async function invoke(argumentsFor,env={}) {
37
+ const dir=mkdtempSync(join(tmpdir(),'buninu-bind-cli-')),root=join(dir,'root'),host=join(dir,'host'),log=join(dir,'calls');
38
+ mkdirSync(root);mkdirSync(host);mkdirSync(join(dir,'fail'));writeFileSync(join(dir,'file'),'data');
39
+ const mock=join(dir,'libc.js');
40
+ writeFileSync(mock,`import {appendFileSync} from 'node:fs';
41
+ const record=(name,...args)=>appendFileSync(${JSON.stringify(log)},JSON.stringify([name,...args.map(a=>typeof a==='bigint'?String(a):a)])+'\\n');
42
+ export const ptr=x=>x,cString=x=>x,errno=()=>1,strerror=()=> 'mock mount denied',Errno={EINVAL:22,EPERM:1};
43
+ export const libc={symbols:{mount:(...a)=>{record('mount',...a);return a[0]?.endsWith('/fail')?-1:0;},umount2:(...a)=>{record('umount',...a);return 0;},chroot:(...a)=>{record('chroot',...a);return 0;}}};`);
44
+ const source=readFileSync(new URL('../initramfs/bin/chroot',import.meta.url),'utf8')
45
+ .replace('"../lib/dlopen.js"',JSON.stringify(mock))
46
+ .replace('"../lib/chroot-options.js"',JSON.stringify(new URL('../initramfs/lib/chroot-options.js',import.meta.url).pathname))
47
+ .replace('import { showDocument } from "../lib/document.js";','const showDocument=async()=>{};');
48
+ const entry=join(dir,'chroot.mjs');writeFileSync(entry,source);
49
+ try{
50
+ const child=Bun.spawn([process.execPath,entry,...argumentsFor({dir,root,host}),root,'/bin/true'],{env:{...process.env,...env,TMPDIR:host},stdout:'pipe',stderr:'pipe'});
51
+ const stderr=new Response(child.stderr).text();const status=await child.exited;
52
+ return {status,stderr:await stderr,calls:readFileSync(log,'utf8').trim().split('\n').map(JSON.parse),root,host,dir};
53
+ }finally{rmSync(dir,{recursive:true,force:true});}
54
+ }
55
+
56
+ test('explicit binds work with -n, create files, and unwind in reverse depth order',async()=>{
57
+ const r=await invoke(({dir,host})=>['-n','-b',`${host}:/work`,'-b',`${dir}/file:/work/config/file`]);
58
+ expect(r.status).toBe(0);
59
+ expect(r.calls.filter(c=>c[0]==='mount'&&c[1]===r.host)[0]).toEqual(['mount',r.host,r.root+'/work','none','20480',null]);
60
+ expect(r.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual(['/work/config/file','/work']);
61
+ });
62
+ test('shared tmp replaces default tmpfs and bind failure cleans up even with -k',async()=>{
63
+ const shared=await invoke(()=>['--shared-tmp']);expect(shared.status).toBe(0);
64
+ expect(shared.calls.some(c=>c[0]==='mount'&&c[1]===shared.host&&c[2]===shared.root+'/tmp')).toBe(true);
65
+ expect(shared.calls.some(c=>c[0]==='mount'&&c[1]==='tmpfs'&&c[2]===shared.root+'/tmp')).toBe(false);
66
+ const failure=await invoke(({host,dir})=>['-n','-k','-b',`${host}:/first`,'-b',`${dir}/fail:/second`]);
67
+ expect(failure.status).toBe(125);expect(failure.stderr).toContain('mock mount denied');
68
+ expect(failure.calls.some(c=>c[0]==='chroot')).toBe(false);
69
+ expect(failure.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual([failure.root+'/first']);
70
+ });
@@ -21,9 +21,9 @@ const server = Bun.serve({
21
21
  afterAll(() => { server.stop(true); rmSync(work, { recursive: true, force: true }); });
22
22
  const url = `http://127.0.0.1:${server.port}/archive`;
23
23
 
24
- test("four exact registry pins share an architecture-independent cache", () => {
24
+ test("five exact registry pins share an architecture-independent cache", () => {
25
25
  expect(npmPackages.map(({ name, version }) => `${name}@${version}`)).toEqual([
26
- "buninu-browser@0.2.3", "@drxiaozhi/casty@1.3.4",
26
+ "buninu-browser@0.2.3", "buninu-land@0.1.2", "@drxiaozhi/casty@1.3.6",
27
27
  "@drxiaozhi/jspulse@0.2.1", "@drxiaozhi/jspinyin@0.1.1",
28
28
  ]);
29
29
  expect(basename(npmDownloadsDir)).toBe("npm");
@@ -16,7 +16,7 @@ const fresh = () => { const dir = join(work, String(sequence++)); mkdirSync(dir)
16
16
  async function fixture() {
17
17
  const downloadsDir = fresh();
18
18
  const packages = [];
19
- for (const name of ["buninu-browser", "@drxiaozhi/casty", "@drxiaozhi/jspulse", "@drxiaozhi/jspinyin"]) {
19
+ for (const name of ["buninu-browser", "buninu-land", "@drxiaozhi/casty", "@drxiaozhi/jspulse", "@drxiaozhi/jspinyin"]) {
20
20
  const bin = name.split("/").pop();
21
21
  const data = await new Bun.Archive({
22
22
  "package/package.json": JSON.stringify({ name, version: "1.0.0", type: "module", bin: { [bin]: "index.js" } }),
@@ -104,6 +104,39 @@ test("distribution launcher ensures resources then forwards to local casty witho
104
104
  expect(JSON.parse(result.stdout)).toEqual({ args: ["https://example.test", "--", "--chromium"], engine: join(browser, "buninu-browser.js") });
105
105
  });
106
106
 
107
+ test("land launcher selects bundled casty and preserves package frontend dispatch", async () => {
108
+ const cfg = await fixture(), root = fresh();
109
+ stageNpm(root, cfg);
110
+ const land = join(root, "usr/lib/node_modules/buninu-land");
111
+ mkdirSync(join(root, "bin"));
112
+ const launcher = join(root, "bin/buninu-land");
113
+ copyFileSync(new URL("../initramfs/bin/buninu-land", import.meta.url), launcher);
114
+ writeFileSync(join(land, "index.js"), `
115
+ export const chooseFrontend = args => ({
116
+ frontend: !args.length || args[0] === '--casty' || args[0] === 'about:blank' ? 'casty' : 'other',
117
+ args: args[0] === '--casty' ? args.slice(1) : args,
118
+ });
119
+ if (import.meta.main) console.log(JSON.stringify({ package: true, args: process.argv.slice(2) }));
120
+ `);
121
+ writeFileSync(join(root, "usr/lib/node_modules/@drxiaozhi/casty/index.js"),
122
+ "console.log(JSON.stringify({args:process.argv.slice(2),engine:process.env.CASTY_BROWSER}));");
123
+ for (const args of [[], ['about:blank'], ['--casty', '--', '--socket=wayland-test']]) {
124
+ const result = spawnSync(process.execPath, [launcher, ...args], {
125
+ encoding: "utf8", env: { ...process.env, CASTY_BROWSER: '/previous/browser' },
126
+ });
127
+ expect(result.status).toBe(0);
128
+ expect(JSON.parse(result.stdout)).toEqual({
129
+ args: args[0] === '--casty' ? args.slice(1) : args,
130
+ engine: join(land, "compositor.js"),
131
+ });
132
+ }
133
+ for (const args of [['--headless', '--help'], ['--help'], ['--version'], ['--docs'], ['--gtk']]) {
134
+ const result = spawnSync(process.execPath, [launcher, ...args], { encoding: "utf8" });
135
+ expect(result.status).toBe(0);
136
+ expect(JSON.parse(result.stdout)).toEqual({ package: true, args });
137
+ }
138
+ });
139
+
107
140
  test.skipIf(!npmPackages.every(pkg => existsSync(join(npmDownloadsDir, pkg.archive))))("real cached pins: JS and shell staging agree; browser headless help starts", () => {
108
141
  const js = fresh(), shell = fresh();
109
142
  stageNpm(js);
@@ -127,4 +160,12 @@ test.skipIf(!npmPackages.every(pkg => existsSync(join(npmDownloadsDir, pkg.archi
127
160
  const help = spawnSync(process.execPath, [join(js, "bin/buninu-browser"), "--headless", "--help"], { encoding: "utf8", timeout: 15000 });
128
161
  expect(help.status).toBe(0);
129
162
  expect(help.stdout).toContain("Usage");
163
+ copyFileSync(new URL("../initramfs/bin/buninu-land", import.meta.url), join(js, "bin/buninu-land"));
164
+ for (const args of [["--help"], ["--headless", "--help"], ["--version"]]) {
165
+ const landHelp = spawnSync(process.execPath, [join(js, "bin/buninu-land"), ...args], { encoding: "utf8", timeout: 15000 });
166
+ expect(landHelp.status).toBe(0);
167
+ expect(landHelp.stdout).toContain(args[0] === "--version" ? "0.1.2" : args[0] === "--headless" ? "Wayland compositor" : "Usage");
168
+ }
169
+ expect(existsSync(join(js, "usr/lib/node_modules/buninu-land/vendor/canvaskit/canvaskit.wasm"))).toBe(true);
170
+ expect(existsSync(join(js, "usr/lib/node_modules/buninu-land/LICENSES/hyprland.txt"))).toBe(true);
130
171
  }, 30000);