buninu-linux 0.3.1 → 0.5.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.
- package/LICENSES/Apache-2.0.txt +202 -0
- package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
- package/LICENSES/Bitstream-Vera.txt +44 -0
- package/LICENSES/MIT-xterm.js.txt +21 -0
- package/LICENSES/OFL-1.1.txt +91 -0
- package/NOTICE.md +18 -1
- package/README.md +101 -9
- package/fetch-alpine.sh +6 -0
- package/graphics.md +218 -0
- package/initramfs/bin/bunterm +70 -0
- package/initramfs/bin/chroot +213 -0
- package/initramfs/bin/stripansi +27 -0
- package/initramfs/bin/tar +189 -14
- package/initramfs/buninu/CHANGELOG.md +40 -0
- package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +80 -1
- package/initramfs/lib/bunterm/glyphs.js +446 -0
- package/initramfs/lib/bunterm/images.js +225 -0
- package/initramfs/lib/bunterm/input.js +140 -0
- package/initramfs/lib/bunterm/mouse.js +222 -0
- package/initramfs/lib/bunterm/renderer.js +469 -0
- package/initramfs/lib/bunterm/terminal.js +324 -0
- package/initramfs/lib/canvas.js +181 -0
- package/initramfs/lib/canvaskit/LICENSE +29 -0
- package/initramfs/lib/canvaskit/canvaskit.js +264 -0
- package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
- package/initramfs/lib/dlopen.js +18 -2
- package/initramfs/lib/document.js +5 -1
- package/initramfs/lib/fbdev.js +581 -0
- package/initramfs/lib/xterm/LICENSE +21 -0
- package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
- package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +112 -0
- package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
- package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
- package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
- package/initramfs/usr/share/doc/buninu-linux/tar.md +30 -6
- package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
- package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
- package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
- package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
- package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
- package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
- package/initramfs/usr/share/fonts/fonts.json +28 -0
- package/package.json +2 -2
- package/run-qemu.sh +1 -1
package/graphics.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# The Buninu Linux graphics stack
|
|
2
|
+
|
|
3
|
+
Buninu Linux draws on the screen without X11, Wayland, a GPU or any native
|
|
4
|
+
graphics library: the Linux framebuffer is mapped into Bun, Skia (compiled to
|
|
5
|
+
WebAssembly as CanvasKit) rasterizes into it, and `bunterm` puts an
|
|
6
|
+
xterm-compatible terminal on top. Everything is JavaScript or WebAssembly
|
|
7
|
+
except the kernel's `/dev/fb0`, reached through a few `ioctl`s and one `mmap`
|
|
8
|
+
via Bun FFI.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
12
|
+
│ programs on a PTY: bunmsh, jsmdcui, bun scripts, kitten icat … │
|
|
13
|
+
└───────────────▲───────────────────────────────┬──────────────────────┘
|
|
14
|
+
keyboard │ (raw console bytes) │ output bytes + kitty APC
|
|
15
|
+
┌───────────────┴───────────────────────────────▼──────────────────────┐
|
|
16
|
+
│ /bin/bunterm → /lib/bunterm/terminal.js │
|
|
17
|
+
│ BunPtyBackend (jsgotty) ─ Bun.spawn({ terminal }) ← Bun's PTY │
|
|
18
|
+
│ KittyGraphicsParser (jsgotty) ─ splits ESC _ G … ESC \ packets │
|
|
19
|
+
│ @xterm/headless Terminal ─ VT parser, screen buffer, attributes │
|
|
20
|
+
│ images.js ─ kitty placements as Skia images anchored to lines │
|
|
21
|
+
│ input.js ─ /dev/ttyN raw mode, console→xterm key sequences │
|
|
22
|
+
│ mouse.js ─ /dev/input/event* pointer, unless --no-mouse │
|
|
23
|
+
│ renderer.js ─ cells → glyphs (fonts) or vectors (glyphs.js) │
|
|
24
|
+
└───────────────────────────────────────────────┬──────────────────────┘
|
|
25
|
+
│ Skia draw calls
|
|
26
|
+
┌───────────────────────────────────────────────▼──────────────────────┐
|
|
27
|
+
│ /lib/canvas.js ─ CanvasKit 0.41.1 (Skia in wasm, /lib/canvaskit/) │
|
|
28
|
+
│ openCanvas(): MakeRasterDirectSurface in the framebuffer's own │
|
|
29
|
+
│ pixel format (BGRA_8888 / RGBA_8888 / RGB_565) inside the wasm heap │
|
|
30
|
+
│ loadFontSet(): /usr/share/fonts/fonts.json → Typefaces + FontMgr │
|
|
31
|
+
└───────────────────────────────────────────────┬──────────────────────┘
|
|
32
|
+
│ one memcpy per frame
|
|
33
|
+
┌───────────────────────────────────────────────▼──────────────────────┐
|
|
34
|
+
│ /lib/fbdev.js ─ Framebuffer: FBIOGET_*SCREENINFO, mmap, KDSETMODE │
|
|
35
|
+
│ runGraphics(): graphics mode + restore on exit/signal │
|
|
36
|
+
│ JS drawing (drawLine, fillPolygon …) and blitImageData() fallback │
|
|
37
|
+
└───────────────────────────────────────────────┬──────────────────────┘
|
|
38
|
+
│ ioctl / mmap (bun:ffi, /lib/dlopen.js)
|
|
39
|
+
┌───────────────────────────────────────────────▼──────────────────────┐
|
|
40
|
+
│ Linux: /dev/fb0 (efifb / simpledrm from the UEFI GOP), /dev/ttyN │
|
|
41
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Layers
|
|
45
|
+
|
|
46
|
+
### 1. Framebuffer — `/lib/fbdev.js`
|
|
47
|
+
|
|
48
|
+
`Framebuffer` opens `/dev/fb0`, reads the variable and fixed screen info
|
|
49
|
+
(size, stride, bits per pixel, the red/green/blue bit offsets) and `mmap`s
|
|
50
|
+
the device. Drawing goes to a JavaScript back buffer laid out exactly like
|
|
51
|
+
the device; `flush()` copies it row by row into the mapping. Nothing draws
|
|
52
|
+
straight into the mapping: on efifb the memory is write-combining, reads are
|
|
53
|
+
very slow, and drawing there would tear.
|
|
54
|
+
|
|
55
|
+
`runGraphics(draw)` wraps a graphical program: it switches the virtual
|
|
56
|
+
console to `KD_GRAPHICS` (otherwise the kernel's text console keeps
|
|
57
|
+
overwriting the screen), runs `draw`, and restores `KD_TEXT` whether the
|
|
58
|
+
call returns, throws, exits, or is killed by a catchable signal. Given a
|
|
59
|
+
specific `/dev/ttyN`, it `VT_ACTIVATE`s that console first and switches
|
|
60
|
+
back to the previous one at the end; while drawing it takes `VT_PROCESS`
|
|
61
|
+
mode, so Ctrl-Alt-Fn still switches consoles and `onAcquire` lets the
|
|
62
|
+
program repaint when its console returns.
|
|
63
|
+
|
|
64
|
+
Manual: `fbdev --help` / `initramfs/usr/share/doc/buninu-linux/fbdev.md`.
|
|
65
|
+
|
|
66
|
+
### 2. Skia — `/lib/canvas.js` and `/lib/canvaskit/`
|
|
67
|
+
|
|
68
|
+
CanvasKit is Google's Skia compiled to WebAssembly (the same npm package the
|
|
69
|
+
browser uses). WebAssembly can only write its own linear memory, so Skia
|
|
70
|
+
cannot draw into the `mmap` directly. Instead `openCanvas()` allocates a
|
|
71
|
+
buffer in the wasm heap and creates a `MakeRasterDirectSurface` on it in the
|
|
72
|
+
framebuffer's native pixel layout (`pickColorType()` maps the kernel's
|
|
73
|
+
bitfields to `BGRA_8888` for the usual XRGB8888, or `RGBA_8888` /
|
|
74
|
+
`RGB_565`). The `Framebuffer`'s `pixels` view is pointed at that buffer, so
|
|
75
|
+
`flush()` is the same single `memcpy` as before and the JavaScript drawing
|
|
76
|
+
functions keep working on the same frame. A 24-bit or paletted device falls
|
|
77
|
+
back to CanvasKit's HTML-canvas emulation plus `blitImageData()`, which
|
|
78
|
+
converts per pixel.
|
|
79
|
+
|
|
80
|
+
Skia is CPU-only here (no GPU, no WebGL); the whole 1080p frame is a few
|
|
81
|
+
milliseconds to copy and cells are cached by Skia's glyph cache. The wasm
|
|
82
|
+
heap starts at 128 MiB and grows as fonts are loaded into it, which is why
|
|
83
|
+
QEMU is now started with `-m 1G`.
|
|
84
|
+
|
|
85
|
+
`loadFontSet()` reads `/usr/share/fonts/fonts.json` and builds the typefaces
|
|
86
|
+
and a `FontMgr` for a named set (`terminal`, `ui`). Noto Sans CJK is a
|
|
87
|
+
collection; the manager registers all five faces and the set's preferred
|
|
88
|
+
face (`Noto Sans CJK TC`) is the one handed out, so Taiwanese glyph forms
|
|
89
|
+
are used without relying on `locl`.
|
|
90
|
+
|
|
91
|
+
Manual: `initramfs/usr/share/doc/buninu-linux/canvas.md`.
|
|
92
|
+
|
|
93
|
+
### 3. Fonts — `/usr/share/fonts/`
|
|
94
|
+
|
|
95
|
+
| File | Role | License |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `DejaVuSansMono.ttf`, `-Bold.ttf` | terminal grid font: 0.6 em wide, 1.16 em tall, box drawing, arrows, `☐☒✓`, Braille-free but everything jsmdcui draws | Bitstream Vera |
|
|
98
|
+
| `NotoSansCJK-Regular.ttc` | 32 MiB variable collection: JP/KR/SC/TC/HK faces, ~21 000 unified ideographs, ext. A, part of ext. B, kana, bopomofo, hangul, full-width forms | OFL 1.1 |
|
|
99
|
+
| `NotoColorEmoji.ttf`, `NotoColorEmojiFlags.ttf` | CBDT bitmap colour emoji; Skia's FreeType port renders them in colour | OFL 1.1 |
|
|
100
|
+
| `NotoSansSymbols-Regular-Subsetted.ttf`, `-Subsetted2.ttf` | arrows, math, geometric shapes, dingbats, Braille, musical symbols, cards | OFL 1.1 |
|
|
101
|
+
| `Roboto-Regular.ttf` | variable Latin/Greek/Cyrillic for non-terminal UI (`ui` set) | Apache 2.0 |
|
|
102
|
+
|
|
103
|
+
The Noto and Roboto files are the ones Android ships; DejaVu comes from
|
|
104
|
+
Debian. Each license text sits next to the fonts and in `LICENSES/`.
|
|
105
|
+
|
|
106
|
+
### 4. Terminal — `/bin/bunterm` and `/lib/bunterm/`
|
|
107
|
+
|
|
108
|
+
`bunterm` reuses three finished components instead of writing a terminal
|
|
109
|
+
from scratch:
|
|
110
|
+
|
|
111
|
+
- **PTY**: jsgotty's `BunPtyBackend`, which is `Bun.spawn(cmd, { terminal:
|
|
112
|
+
{ cols, rows, data, exit } })` — Bun's built-in pseudo-terminal, no
|
|
113
|
+
node-pty, no FFI. jsgotty now exports the class for this.
|
|
114
|
+
- **Emulation**: `@xterm/headless`, the xterm.js core (VT parser, screen
|
|
115
|
+
and scrollback buffers, SGR attributes, terminal replies, markers) with
|
|
116
|
+
its DOM renderer removed. `@xterm/addon-unicode-graphemes` makes 🇹🇼,
|
|
117
|
+
ZWJ sequences and combining marks occupy one cell.
|
|
118
|
+
- **Images**: jsgotty's `KittyGraphicsParser`, which splits the kitty
|
|
119
|
+
graphics APC packets out of the byte stream, reassembles chunked
|
|
120
|
+
transfers, inflates `o=z`, and produces placement / delete / query events
|
|
121
|
+
and the replies to send back. (Two small changes to the vendored jsgotty:
|
|
122
|
+
the export, and later chunks of a transfer whose first chunk named `i=`
|
|
123
|
+
now continue that transfer, as the protocol allows — to be carried back
|
|
124
|
+
to the jsgotty repository.)
|
|
125
|
+
|
|
126
|
+
Around those, `/lib/bunterm/` adds:
|
|
127
|
+
|
|
128
|
+
| File | Does |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `renderer.js` | Paints `term.buffer` cell by cell. Cell size comes from DejaVu Sans Mono; every glyph is looked up in the regular font, then the fallbacks (colour emoji first for emoji-presentation code points, monochrome symbols first otherwise) and centred in its one or two cells; oversized fallback glyphs are scaled to fit. Multi-code-point graphemes are shaped by Skia's `Paragraph` and cached. Only rows whose content hash changed since the last frame, the cursor rows, and rows under moved images are repainted. 16/256/24-bit colour, bold (with bright base colours), italic, dim, underline, strikethrough, inverse, invisible. |
|
|
131
|
+
| `glyphs.js` | Box drawing (U+2500–257F incl. double, dashed, rounded, mixed-weight), block elements and shades (U+2580–259F), Powerline (U+E0A0–E0BF) and legacy computing (U+1FB70–1FB97) drawn as Skia paths, so they meet seamlessly at any size. The shape tables are xterm.js's `CustomGlyphs.ts` (MIT); coordinates snap to pixel centres for crisp 1-px lines. |
|
|
132
|
+
| `images.js` | Kitty placements: decodes PNG (`f=100`) with Skia or raw RGB/RGBA (`f=24/32`), with `Bun.Image` as a transcoder for other encodings; anchors each placement to an xterm marker so it scrolls with its line and disappears with it (on the alternate screen, where xterm.js issues no markers, to a fixed row that is dropped when that screen ends); sizes in cells (`c,r`) or from pixel size; crops (`x,y,w,h`), cell offsets (`X,Y`), `p=` placement ids, `a=d` deletion scopes, and the cursor move after a placement. |
|
|
133
|
+
| `input.js` | Raw mode on the console (`TCGETS`/`TCSETS` through the shared libc binding) and translation of the kernel's "linux" key sequences to xterm's, honouring DECCKM. |
|
|
134
|
+
| `mouse.js` | `/dev/input/event*` through the input layer: finds pointing devices by their sysfs capability bitmaps, decodes the 24-byte `input_event` records (relative deltas, absolute axes read with `EVIOCGABS`, buttons, wheel) and reports a pointer position per `EV_SYN`. Not loaded under `--no-mouse`; otherwise the session draws an arrow and hands each report to xterm.js's `CoreMouseService`, which decides what the program enabled and encodes it. A wheel notch the program is not asking for scrolls the viewport instead — `scrollLines()` on the normal screen, arrow keys on the alternate one. |
|
|
135
|
+
| `terminal.js` | `createSession()` wires PTY → parser → emulator → renderer with a coalesced ~60 Hz frame timer and a serialized output queue, so a kitty packet always sees the cursor position left by the text before it. `runTerminal()` adds `runGraphics()` and the keyboard. |
|
|
136
|
+
|
|
137
|
+
Manual: `bunterm --help` / `initramfs/usr/share/doc/buninu-linux/bunterm.md`.
|
|
138
|
+
|
|
139
|
+
## Data flow of one frame
|
|
140
|
+
|
|
141
|
+
1. The program writes to the PTY; Bun delivers the bytes to `BunPtyBackend`'s
|
|
142
|
+
`data` callback.
|
|
143
|
+
2. `KittyGraphicsParser.consume()` returns plain-text runs and kitty packets
|
|
144
|
+
in order. Plain runs go to `term.write()` (awaited, so the buffer is
|
|
145
|
+
current); each packet is parsed with the cursor position at that point,
|
|
146
|
+
handed to `ImageStore`, and acknowledged back to the PTY if the client
|
|
147
|
+
asked.
|
|
148
|
+
3. A frame is requested. Sixteen milliseconds later `Renderer.render()`
|
|
149
|
+
hashes each visible row, repaints the changed ones (background rect,
|
|
150
|
+
glyph or vector, underline), draws the cursor block, and re-draws the
|
|
151
|
+
parts of image placements that intersect the repainted rows.
|
|
152
|
+
4. `screen.flush()` finishes Skia's work and copies the wasm-heap frame into
|
|
153
|
+
the `/dev/fb0` mapping.
|
|
154
|
+
|
|
155
|
+
Keyboard bytes travel the other way: console → `input.js` → PTY, and with
|
|
156
|
+
the mouse on, pointer reports go `/dev/input/event*` → `mouse.js` → the arrow on
|
|
157
|
+
screen and xterm.js's `CoreMouseService` → PTY. Terminal
|
|
158
|
+
replies (cursor position, device attributes, kitty acks) go from the
|
|
159
|
+
emulator straight to the PTY.
|
|
160
|
+
|
|
161
|
+
## Sizes
|
|
162
|
+
|
|
163
|
+
| Component | Raw | In the gzip initramfs |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| CanvasKit wasm + js | 7.3 MB | ~2.7 MB |
|
|
166
|
+
| xterm headless + graphemes addon | 0.2 MB | ~0.05 MB |
|
|
167
|
+
| Fonts | 39.9 MB | ~24 MB |
|
|
168
|
+
| Total added | ~47 MB | ~27 MB |
|
|
169
|
+
|
|
170
|
+
The initramfs grew from 44.7 MB to 71.8 MB compressed; the UKI is 84.5 MB and
|
|
171
|
+
the 127 MB EFI system partition keeps 46 MB free. The initramfs is unpacked
|
|
172
|
+
into RAM, and CanvasKit copies the fonts it uses into its heap, so the guest
|
|
173
|
+
needs about 1 GB.
|
|
174
|
+
|
|
175
|
+
## Trying it
|
|
176
|
+
|
|
177
|
+
The framebuffer needs a kernel that provides one. Alpine's `linux-lts` has
|
|
178
|
+
`CONFIG_FB`, `CONFIG_FB_EFI` and `CONFIG_DRM_SIMPLEDRM` built in; the default
|
|
179
|
+
`linux-virt` builds them as modules, and the image ships only the network,
|
|
180
|
+
storage, USB and HID modules, so there `/dev/fb0` never appears. Build with
|
|
181
|
+
`./index.js -b --linux-lts` (or `--real`); under QEMU the lts kernel gets a
|
|
182
|
+
1280×800 `simpledrm` framebuffer from the UEFI GOP even with `-display none`,
|
|
183
|
+
and the monitor's `screendump` shows it. From a virtual console in the guest:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
bunterm # /bin/sh on this console
|
|
187
|
+
bunterm /dev/tty1 --font-size 20 # from a serial console, use VT 1
|
|
188
|
+
bunterm -e bun /buninu/apps/jsmdcui/src/index.js --demo
|
|
189
|
+
bun /lib/fbdev.js # the plain-framebuffer triangle
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
To render off-screen on the build host (no framebuffer), give `openCanvas()`
|
|
193
|
+
a plain object with `width`, `height`, `stride`, `bitsPerPixel`, `fields` and
|
|
194
|
+
a `pixels` `Uint8Array`, replace `screen.flush`, and snapshot the surface —
|
|
195
|
+
that is how the renderer and the kitty pipeline were tested during
|
|
196
|
+
development (`BUNINU_LIBC=/lib/aarch64-linux-gnu/libc.so.6` lets `dlopen.js`
|
|
197
|
+
load the host libc).
|
|
198
|
+
|
|
199
|
+
Verified in QEMU (lts kernel, `-vga std`, 1 GB): the attribute/CJK/emoji
|
|
200
|
+
sample, a kitty PNG placement, an interactive bunmsh with keys sent through
|
|
201
|
+
the console, and the jsmdcui editor demo all render as on the host;
|
|
202
|
+
`bunterm /dev/tty2` from the serial REPL switches the display to tty2 and
|
|
203
|
+
back to tty1 on exit, and Ctrl-Alt-F2 / Ctrl-Alt-F1 during a session
|
|
204
|
+
switch away and repaint on return. On the
|
|
205
|
+
host, `jsmdcui --tui` on a Markdown file with a JPEG (bunterm sets
|
|
206
|
+
`JSMDCUI_KITTY_MODE=extended` for the program it runs) places the image
|
|
207
|
+
through its transmit / `a=p` / `a=d` sequence exactly as in the browser.
|
|
208
|
+
|
|
209
|
+
## Not done yet
|
|
210
|
+
|
|
211
|
+
- Selection and scrollback viewing in `bunterm` (the pointer itself works,
|
|
212
|
+
and `--no-mouse` turns it off).
|
|
213
|
+
- Kitty `z < 0` (images behind text), animation frames, Unicode
|
|
214
|
+
placeholders, and shared-memory transmission.
|
|
215
|
+
- Text run shaping with ligatures for single-code-point cells (each cell is
|
|
216
|
+
one glyph; Paragraph shaping is only used for grapheme clusters).
|
|
217
|
+
- Non-terminal UI on `canvas.js` (the `ui` font set and Roboto are there for
|
|
218
|
+
it).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// bunterm — a graphical terminal emulator on the Linux framebuffer.
|
|
3
|
+
|
|
4
|
+
import { showDocument } from "../lib/document.js";
|
|
5
|
+
import { runTerminal } from "../lib/bunterm/terminal.js";
|
|
6
|
+
|
|
7
|
+
const usage = () => {
|
|
8
|
+
console.error(`Usage:
|
|
9
|
+
bunterm [/dev/ttyN] [--font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
|
|
10
|
+
bunterm -h | --help`);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const main = async () => {
|
|
14
|
+
const options = { argv: [] };
|
|
15
|
+
const arguments_ = process.argv.slice(2);
|
|
16
|
+
for (let index = 0; index < arguments_.length; index++) {
|
|
17
|
+
const argument = arguments_[index];
|
|
18
|
+
if (argument === "-h" || argument === "--help") {
|
|
19
|
+
await showDocument("bunterm");
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (argument === "-e") {
|
|
23
|
+
if (index + 1 >= arguments_.length) {
|
|
24
|
+
usage();
|
|
25
|
+
throw new Error("-e needs a command");
|
|
26
|
+
}
|
|
27
|
+
options.command = arguments_[index + 1];
|
|
28
|
+
options.argv = arguments_.slice(index + 2);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
const valued = (name) => {
|
|
32
|
+
const value = argument.startsWith(`${name}=`) ? argument.slice(name.length + 1) : arguments_[++index];
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
usage();
|
|
35
|
+
throw new Error(`${name} needs a value`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
if (argument === "--font-size" || argument.startsWith("--font-size=")) {
|
|
40
|
+
options.fontSize = Number(valued("--font-size"));
|
|
41
|
+
if (!Number.isFinite(options.fontSize) || options.fontSize < 6) throw new Error("--font-size must be a number of at least 6");
|
|
42
|
+
} else if (argument === "--line-height" || argument.startsWith("--line-height=")) {
|
|
43
|
+
options.lineHeight = Number(valued("--line-height"));
|
|
44
|
+
if (!Number.isFinite(options.lineHeight) || options.lineHeight <= 0) throw new Error("--line-height must be a positive number");
|
|
45
|
+
} else if (argument === "--fb" || argument.startsWith("--fb=")) {
|
|
46
|
+
options.device = valued("--fb");
|
|
47
|
+
} else if (argument === "--no-blink") {
|
|
48
|
+
options.cursorBlink = false;
|
|
49
|
+
} else if (argument === "--mouse") {
|
|
50
|
+
options.mouse = true;
|
|
51
|
+
} else if (argument === "--no-mouse") {
|
|
52
|
+
options.mouse = false;
|
|
53
|
+
} else if (argument.startsWith("/dev/")) {
|
|
54
|
+
options.console = argument;
|
|
55
|
+
} else {
|
|
56
|
+
usage();
|
|
57
|
+
throw new Error(`unexpected argument: ${argument}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return runTerminal(options);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let status = 1;
|
|
64
|
+
try {
|
|
65
|
+
status = await main();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`bunterm: ${error?.message ?? error}`);
|
|
68
|
+
}
|
|
69
|
+
// A pending console read would otherwise keep the process alive.
|
|
70
|
+
process.exit(status);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// chroot — run a command inside another root directory. `chroot --help`
|
|
3
|
+
// renders /usr/share/doc/buninu-linux/chroot.md.
|
|
4
|
+
//
|
|
5
|
+
// A Bun script over the chroot(2) and mount(2) bindings in /lib/dlopen.js.
|
|
6
|
+
// Unlike chroot(8) it prepares the new root before entering it: /proc, /sys,
|
|
7
|
+
// a recursive bind of /dev, /dev/pts, tmpfs on /run and /tmp, and the host's
|
|
8
|
+
// /etc/resolv.conf, so `chroot /mnt/rootfs` lands in a usable system. Every
|
|
9
|
+
// mount it makes is undone when the command exits.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
|
|
13
|
+
import { showDocument } from "../lib/document.js";
|
|
14
|
+
|
|
15
|
+
const MS = { RDONLY: 1n, NOSUID: 2n, NODEV: 4n, NOEXEC: 8n, BIND: 4096n, REC: 16384n, SLAVE: 1n << 19n };
|
|
16
|
+
const MNT_DETACH = 2;
|
|
17
|
+
|
|
18
|
+
const fail = (message, status = 125) => {
|
|
19
|
+
console.error(`chroot: ${message}`);
|
|
20
|
+
process.exit(status);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const usage = () => {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
chroot NEWROOT [COMMAND [ARGUMENT...]]
|
|
26
|
+
chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
|
|
27
|
+
chroot -h | --help full manual (chroot.md)`);
|
|
28
|
+
process.exit(125);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------- arguments --
|
|
32
|
+
|
|
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
|
+
}
|
|
47
|
+
if (positional.length === 0) usage();
|
|
48
|
+
|
|
49
|
+
const [target, ...command] = positional;
|
|
50
|
+
let root;
|
|
51
|
+
try {
|
|
52
|
+
root = realpathSync(target);
|
|
53
|
+
if (!statSync(root).isDirectory()) fail(`cannot change root directory to ${target}: not a directory`);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
fail(`cannot change root directory to ${target}: ${error.code === "ENOENT" ? "no such directory" : error.message}`);
|
|
56
|
+
}
|
|
57
|
+
if (root === "/") fail("refusing to chroot to /");
|
|
58
|
+
|
|
59
|
+
const report = (message) => { if (options.verbose) console.error(`chroot: ${message}`); };
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- mounts --
|
|
62
|
+
|
|
63
|
+
const mountPoints = () => {
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync("/proc/self/mounts", "utf8").split("\n").filter(Boolean).map((line) =>
|
|
66
|
+
line.split(" ")[1].replace(/\\([0-7]{3})/g, (_, code) => String.fromCharCode(parseInt(code, 8))));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const mountSyscall = (source, point, type, flags, data = null) =>
|
|
73
|
+
libc.symbols.mount(ptr(cString(source)), ptr(cString(point)), ptr(cString(type)), flags,
|
|
74
|
+
data === null ? null : ptr(cString(data)));
|
|
75
|
+
|
|
76
|
+
// Mount points this command made, innermost last, as paths inside the new
|
|
77
|
+
// root; they are undone in reverse order when the command is done.
|
|
78
|
+
const made = [];
|
|
79
|
+
|
|
80
|
+
// Prepares one mount. `inside` is the path within the new root. A target that
|
|
81
|
+
// is already a mount point, or that cannot be created on a read-only root, is
|
|
82
|
+
// left alone rather than treated as a failure: the command may still run.
|
|
83
|
+
const prepare = ({ inside, source, type, flags = 0n, data, file = false }) => {
|
|
84
|
+
const point = `${root}${inside}`;
|
|
85
|
+
if (mountPoints().includes(point)) {
|
|
86
|
+
report(`${inside} is already mounted`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!existsSync(point)) {
|
|
90
|
+
try {
|
|
91
|
+
if (file) writeFileSync(point, "");
|
|
92
|
+
else mkdirSync(point, { recursive: true, mode: 0o755 });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
report(`cannot create ${inside}: ${error.message}; skipped`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (mountSyscall(source, point, type, flags, data) === -1) {
|
|
99
|
+
report(`mount ${inside}: ${strerror(errno())}; skipped`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
made.push(inside);
|
|
103
|
+
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
|
+
}
|
|
122
|
+
}
|
|
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
|
+
prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mount points are recorded by their path inside the new root. Until
|
|
133
|
+
// chroot(2) has happened they are reached through this prefix; afterwards
|
|
134
|
+
// that is how /proc/self/mounts names them too.
|
|
135
|
+
let prefix = root;
|
|
136
|
+
|
|
137
|
+
// Unmounts everything this command mounted, deepest first, including whatever
|
|
138
|
+
// appeared under a recursive bind. A busy mount is detached instead: the
|
|
139
|
+
// kernel finishes it once its last user is gone.
|
|
140
|
+
const cleanup = () => {
|
|
141
|
+
if (options.keep || made.length === 0) return;
|
|
142
|
+
const points = mountPoints();
|
|
143
|
+
const targets = new Set();
|
|
144
|
+
for (const inside of made) {
|
|
145
|
+
const point = `${prefix}${inside}`;
|
|
146
|
+
targets.add(point);
|
|
147
|
+
for (const below of points) if (below.startsWith(`${point}/`)) targets.add(below);
|
|
148
|
+
}
|
|
149
|
+
for (const point of [...targets].sort((a, b) => b.length - a.length)) {
|
|
150
|
+
if (libc.symbols.umount2(ptr(cString(point)), 0) === 0) {
|
|
151
|
+
report(`unmounted ${point}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const code = errno();
|
|
155
|
+
if (code === Errno.EINVAL) continue; // not mounted any more
|
|
156
|
+
if (libc.symbols.umount2(ptr(cString(point)), MNT_DETACH) === 0) {
|
|
157
|
+
report(`detached ${point} (was busy)`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
console.error(`chroot: unmount ${point}: ${strerror(errno())}`);
|
|
161
|
+
}
|
|
162
|
+
made.length = 0;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
process.on("exit", cleanup);
|
|
166
|
+
|
|
167
|
+
// ----------------------------------------------------------------- enter --
|
|
168
|
+
|
|
169
|
+
if (libc.symbols.chroot(ptr(cString(root))) === -1) {
|
|
170
|
+
const code = errno();
|
|
171
|
+
cleanup();
|
|
172
|
+
fail(`cannot change root directory to ${target}: ${code === Errno.EPERM ? "must be superuser" : strerror(code)}`);
|
|
173
|
+
}
|
|
174
|
+
prefix = "";
|
|
175
|
+
process.chdir("/");
|
|
176
|
+
|
|
177
|
+
const shell = process.env.SHELL && existsSync(process.env.SHELL) ? process.env.SHELL : "/bin/sh";
|
|
178
|
+
// chroot(8) runs an interactive shell when no command is given.
|
|
179
|
+
const run = command.length ? command : [shell, ...(process.stdin.isTTY ? ["-i"] : [])];
|
|
180
|
+
|
|
181
|
+
// The command owns the terminal while it runs: a Ctrl-C reaches it through
|
|
182
|
+
// the foreground process group, and this process stays alive to unmount.
|
|
183
|
+
const signalNumbers = {
|
|
184
|
+
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7,
|
|
185
|
+
SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13,
|
|
186
|
+
SIGALRM: 14, SIGTERM: 15,
|
|
187
|
+
};
|
|
188
|
+
const ignored = ["SIGINT", "SIGQUIT", "SIGTSTP"];
|
|
189
|
+
const ignore = () => {};
|
|
190
|
+
for (const signal of ignored) process.on(signal, ignore);
|
|
191
|
+
|
|
192
|
+
let status = 0;
|
|
193
|
+
try {
|
|
194
|
+
const child = Bun.spawn(run, {
|
|
195
|
+
cwd: "/",
|
|
196
|
+
env: { PATH: "/bin:/usr/bin:/sbin:/usr/sbin", ...process.env },
|
|
197
|
+
stdin: "inherit",
|
|
198
|
+
stdout: "inherit",
|
|
199
|
+
stderr: "inherit",
|
|
200
|
+
});
|
|
201
|
+
const forward = (signal) => { try { child.kill(signal); } catch {} };
|
|
202
|
+
for (const signal of ["SIGTERM", "SIGHUP"]) process.on(signal, forward);
|
|
203
|
+
await child.exited;
|
|
204
|
+
status = child.exitCode ?? (128 + (signalNumbers[child.signalCode] ?? 0));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error(`chroot: failed to run ${run[0]}: ${error.message}`);
|
|
207
|
+
status = error.code === "ENOENT" ? 127 : 126;
|
|
208
|
+
} finally {
|
|
209
|
+
for (const signal of ignored) process.off(signal, ignore);
|
|
210
|
+
cleanup();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.exit(status);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// stripansi — concatenate text inputs after removing ANSI escape sequences.
|
|
3
|
+
|
|
4
|
+
const operands = process.argv.slice(2);
|
|
5
|
+
const inputs = operands.length === 0
|
|
6
|
+
? ["-"]
|
|
7
|
+
: operands[0] === "--" ? operands.slice(1) : operands;
|
|
8
|
+
|
|
9
|
+
let stdinText;
|
|
10
|
+
let failed = false;
|
|
11
|
+
|
|
12
|
+
const readInput = async (operand) => {
|
|
13
|
+
if (operand !== "-") return Bun.file(operand).text();
|
|
14
|
+
stdinText ??= Bun.stdin.text();
|
|
15
|
+
return stdinText;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
for (const operand of inputs) {
|
|
19
|
+
try {
|
|
20
|
+
process.stdout.write(Bun.stripANSI(await readInput(operand)));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(`stripansi: ${operand}: ${error.message}`);
|
|
23
|
+
failed = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (failed) process.exitCode = 1;
|