buninu 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 - 2026-08-17
4
+
5
+ ### Added
6
+
7
+ - Add a "Commands inside the shell" section to README.md, split out of
8
+ Command-line information (which is only `bin/init.js`'s own flags, resolved
9
+ before Buninu starts). Lists everything in `apps/cmdlist` and moves the
10
+ `buninu-help`/`bunx` write-ups there, including a note that `bunx` can't
11
+ install anything on Android until
12
+ [oven-sh/bun#39084](https://github.com/oven-sh/bun/pull/39084) merges
13
+ upstream (Android's seccomp policy kills `bun i -g` with SIGSYS during bin
14
+ linking until then).
15
+
16
+ ## 0.2.1 - 2026-08-17
17
+
18
+ ### Added
19
+
20
+ - Add `bunx`, a POSIX and Windows multicall command that ensures a package is
21
+ installed via `bun i -g` and then runs its matching binary, forwarding the
22
+ remaining arguments. Reinstall timing is adapted from real bunx's own rules
23
+ (an explicit dist-tag like `@latest` always reinstalls; otherwise a cached
24
+ binary is reused until it's older than 24h, or stat fails, or bun's own
25
+ `install/global/package.json` shows a different pinned version than the one
26
+ requested — this script has one shared install location instead of bunx's
27
+ per-version cache, so this last check substitutes for that). The global bin
28
+ directory is resolved via `bun pm bin -g`, falling back to
29
+ `$BUN_INSTALL`/`$HOME/.bun` on the very first install before that project
30
+ exists (which `bun pm bin -g` requires).
31
+ - Runs the target through `bun <target>` instead of exec'ing it directly:
32
+ on Android the resolved binary typically sits under storage mounted
33
+ noexec, so a direct exec is refused even though the file is readable and
34
+ executable-bit set. Trade-off: a package whose bin is a real native
35
+ executable rather than a JS/bun script won't run this way.
36
+ - On Android (detected the same way as `bin/init.js`, via
37
+ `/system/bin/linker64`), installs with `--backend=copyfile` since
38
+ hardlinks/symlinks frequently fail across Android's storage, and sets
39
+ `$PREFIX=/data/data/com.termux/files/usr` for the target process (not the
40
+ install step) so CLIs that check for a Termux environment still find one,
41
+ unless `$PREFIX` is already set.
42
+
3
43
  ## 0.2.0 - 2026-08-17
4
44
 
5
45
  ### Added
package/README.md CHANGED
@@ -109,10 +109,33 @@ outside the machine.
109
109
  --musl-la [args...] Spawn musl-la directly and exit with its exit code
110
110
  ```
111
111
 
112
- These are flags to `bin/init.js` itself, resolved before Buninu starts. Once you
113
- are inside a running Buninu shell, run `buninu-help` instead: it renders
114
- README.md with jsmdcui's `--cat` mode and then shows `icon.png` with jsgotty's
115
- `--viu`. It is the command the default startup greeting points to.
112
+ These are flags to `bin/init.js` itself, resolved before Buninu starts.
113
+
114
+ ## Commands inside the shell
115
+
116
+ Once you are inside a running Buninu shell, these are available (the
117
+ validated source list is `apps/cmdlist`; see [Add a command](#add-a-command)
118
+ for how it works):
119
+
120
+ ```text
121
+ glow Render files with jsmdcui syntax highlighting
122
+ jmi Open files in the js micro editor
123
+ jsgotty Run a browser-accessible terminal
124
+ jsmdcui Edit and run interactive Markdown applications
125
+ musl-la Launch AArch64 ELF programs with the bundled musl loader
126
+ buninu-help Render README.md with glow, then show icon.png with jsgotty --viu
127
+ bunx Globally install a package with bun, then exec its matching binary
128
+ ```
129
+
130
+ `buninu-help` renders README.md with jsmdcui's `--cat` mode and then shows
131
+ `icon.png` with jsgotty's `--viu`. It is the command the default startup
132
+ greeting points to.
133
+
134
+ `bunx <package>[@version] [args...]` installs with `bun i -g` and runs the
135
+ matching binary. On Android, the underlying `bun i -g` currently needs
136
+ [oven-sh/bun#39084](https://github.com/oven-sh/bun/pull/39084) merged
137
+ upstream — without it, install is killed by SIGSYS (Android's seccomp policy
138
+ rejects a syscall bin-linking uses), so `bunx` can't install anything there yet.
116
139
 
117
140
  ## Export
118
141
 
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // bunx <package>[@version] [args...]
4
+ //
5
+ // Buninu's own bunx: ensures <package> is globally installed via `bun i -g`,
6
+ // then execs the matching ~/.bun/bin/<binary> directly, forwarding the
7
+ // remaining arguments. Registered in apps/cmdlist like any other Buninu
8
+ // command; whether it actually shadows the real `bunx` (Bun's own
9
+ // ephemeral-run subcommand) depends on whether Buninu's bin/ comes before it
10
+ // in PATH -- Buninu appends its bin/ to the inherited PATH rather than
11
+ // prepending it, so an already-installed bun's own bunx will normally still
12
+ // win unless this one is invoked some other way.
13
+ //
14
+ // The "when do we reinstall" rules below are ported from real bunx's own
15
+ // decision tree (src/runtime/cli/bunx_command.rs in the Bun source tree),
16
+ // adapted from its private per-version cache to this script's single shared
17
+ // `bun i -g` install:
18
+ // - an explicit dist-tag (e.g. "latest", "next") never reuses a cached
19
+ // binary -- real bunx sets look_for_existing_bin = false for that case
20
+ // and always resolves fresh.
21
+ // - a missing binary always installs.
22
+ // - an explicit pinned/ranged version reinstalls if bun's own global
23
+ // install record (install/global/package.json) doesn't already list
24
+ // that same version string for this package -- there is no equivalent
25
+ // check in real bunx because its cache is already keyed by version; this
26
+ // is this script's substitute, since `bun i -g` overwrites one shared
27
+ // path regardless of version.
28
+ // - otherwise, reinstall once the binary is older than 24h
29
+ // (SECONDS_CACHE_VALID in bunx_command.rs), or if it can't be stat'd at
30
+ // all -- real bunx also treats a stat failure as stale.
31
+
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+
35
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // matches real bunx's SECONDS_CACHE_VALID
36
+
37
+ function fail(message) {
38
+ console.error(`bunx: ${message}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ // Split "@scope/name@version" (or unscoped variants) into name/version/pkgName.
43
+ // version is "" when the spec didn't include one.
44
+ function parseSpec(spec) {
45
+ let rest = spec;
46
+ let scope = "";
47
+ if (rest.startsWith("@")) {
48
+ const slash = rest.indexOf("/");
49
+ if (slash === -1) fail(`invalid package spec: ${spec}`);
50
+ scope = rest.slice(0, slash);
51
+ rest = rest.slice(slash + 1);
52
+ }
53
+ const at = rest.lastIndexOf("@");
54
+ const name = at === -1 ? rest : rest.slice(0, at);
55
+ const version = at === -1 ? "" : rest.slice(at + 1);
56
+ return { name, version, pkgName: scope ? `${scope}/${name}` : name };
57
+ }
58
+
59
+ // A bare word like "latest"/"next"/"beta"/"canary" is an npm dist-tag.
60
+ // Anything starting with a digit, "v", or a range operator is treated as a
61
+ // real pinned version/range instead.
62
+ function isDistTag(version) {
63
+ return !/^[vV]?\d|^[\^~<>=]/.test(version);
64
+ }
65
+
66
+ // Ask bun itself where its global bin dir is, rather than re-deriving
67
+ // $BUN_INSTALL/$HOME ourselves -- this is the same resolution bun's own
68
+ // installer used, so it can't disagree with where `bun i -g` actually put
69
+ // things (config file settings included, not just env vars).
70
+ //
71
+ // `bun pm bin -g` only works once something has been globally installed at
72
+ // least once (it errors with "no package.json" for install/global before
73
+ // that project exists). On a fresh $BUN_INSTALL/$HOME this script hasn't
74
+ // installed anything yet either, so fall back to the plain env-var
75
+ // computation for that first call -- needsInstall() below will see nothing
76
+ // at that path and install, which creates the project bun pm needs, so every
77
+ // call after the first one resolves through bun pm bin -g instead.
78
+ function globalBinDir() {
79
+ const result = Bun.spawnSync(["bun", "pm", "bin", "-g"], {
80
+ stdio: ["ignore", "pipe", "ignore"],
81
+ });
82
+ if (result.exitCode === 0) return result.stdout.toString().trim();
83
+ return path.join(process.env.BUN_INSTALL || path.join(process.env.HOME, ".bun"), "bin");
84
+ }
85
+
86
+ // `bun i -g` produces bin/<name> on POSIX (a relative symlink resolved via
87
+ // shebang) but bin/<name>.exe on Windows (a compiled shim + a companion
88
+ // .bunx metadata file -- Windows has neither reliable symlinks nor shebang
89
+ // support). Missing this suffix means every Windows lookup below finds
90
+ // nothing, which is harmless: needsInstall() just treats it as "not
91
+ // installed on this platform yet" and installs it here.
92
+ const EXE_SUFFIX = process.platform === "win32" ? ".exe" : "";
93
+
94
+ // Buninu on Android isn't actually running inside Termux, so nothing sets
95
+ // $PREFIX -- but some installed CLIs check it at runtime to find themselves
96
+ // relative to a Termux/Android system. Fake it only for the actual target
97
+ // execution below (not the `bun i -g` install step); an existing $PREFIX
98
+ // (e.g. genuinely running under Termux) is left alone.
99
+ function childEnv() {
100
+ if (process.platform !== "android" || process.env.PREFIX) return process.env;
101
+ return { ...process.env, PREFIX: "/data/data/com.termux/files/usr" };
102
+ }
103
+
104
+ function recordedVersion(bunInstallRoot, pkgName) {
105
+ const manifestPath = path.join(bunInstallRoot, "install", "global", "package.json");
106
+ try {
107
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
108
+ return manifest.dependencies && manifest.dependencies[pkgName];
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ function needsInstall(bunInstallRoot, target, version, pkgName) {
115
+ if (version && isDistTag(version)) return true;
116
+ if (!fs.existsSync(target)) return true;
117
+ if (version && recordedVersion(bunInstallRoot, pkgName) !== version) return true;
118
+ try {
119
+ const age = Date.now() - fs.statSync(target).mtimeMs;
120
+ return age > CACHE_TTL_MS;
121
+ } catch {
122
+ return true;
123
+ }
124
+ }
125
+
126
+ const args = process.argv.slice(2);
127
+ if (args.length < 1) fail("usage: bunx <package>[@version] [args...]");
128
+
129
+ const spec = args[0];
130
+ const forwarded = args.slice(1);
131
+ const { name: bin, version, pkgName } = parseSpec(spec);
132
+ const binDir = globalBinDir();
133
+ const bunInstallRoot = path.dirname(binDir);
134
+ const target = path.join(binDir, bin + EXE_SUFFIX);
135
+
136
+ if (needsInstall(bunInstallRoot, target, version, pkgName)) {
137
+ // Same Android check bin/init.js's detectEnvironment() uses. Android's
138
+ // storage (FUSE-backed app-private paths) frequently rejects hardlinks
139
+ // and symlinks across the directories bun links between, so fall back to
140
+ // plain file copies there; hardlink (bun's default) is fine elsewhere.
141
+ const installArgs = ["bun", "i", "-g", spec];
142
+ if (fs.existsSync("/system/bin/linker64")) installArgs.push("--backend=copyfile");
143
+
144
+ const install = Bun.spawnSync(installArgs, {
145
+ stdio: ["inherit", "inherit", "inherit"],
146
+ });
147
+ if (install.exitCode !== 0) process.exit(install.exitCode ?? 1);
148
+ }
149
+
150
+ if (!fs.existsSync(target)) {
151
+ fail(`${spec} installed but ${target} not found (binary name may differ from package name)`);
152
+ }
153
+
154
+ // Run through `bun <target>` instead of exec'ing `target` directly. Real
155
+ // bunx does the latter (relies on the OS reading the shebang), which is fine
156
+ // on desktop but not reliable here: on Android the resolved bin usually
157
+ // lives under a path mounted noexec, so the kernel refuses to exec it even
158
+ // though it's readable and chmod +x. `bun` itself is the one binary Android
159
+ // does let us execute (from nativeLibraryDir), so hand it the target as an
160
+ // argument to read+run rather than exec directly. Trade-off: a package whose
161
+ // "bin" is a real native executable (not a JS/bun script) won't run this
162
+ // way -- accepted, since that's the rarer case and this is what makes the
163
+ // common (JS-script bin) case work on Android at all.
164
+ const run = Bun.spawnSync(["bun", target, ...forwarded], {
165
+ env: childEnv(),
166
+ stdio: ["inherit", "inherit", "inherit"],
167
+ });
168
+ process.exit(run.exitCode ?? 1);
package/apps/cmdlist CHANGED
@@ -17,3 +17,6 @@ musl-la
17
17
 
18
18
  ### buninu-help — Render README.md with glow, then show icon.png with jsgotty --viu
19
19
  buninu-help
20
+
21
+ ### bunx — Globally install a package with bun, then exec its matching binary
22
+ bunx
package/bin/bunx.bat ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+ call "%~dp0bun.bat" "%~dp0..\apps\bunx\bunx.js" %*
3
+ exit /b %ERRORLEVEL%
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Buninu is a portable, self-bootstrapping Unix-like userspace built on Bun that runs across operating systems",
5
5
  "type": "module",
6
6
  "main": "./bin/init.js",