buninu 0.2.0 → 0.2.3

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 - 2026-08-18
4
+
5
+ ### Added
6
+
7
+ - Add `buninu.exitAfterCmd` package.json setting (default `false`). When
8
+ `true`, the shell/PTY exits with `buninu.command`'s status instead of
9
+ falling back to an interactive shell once the command finishes; `false`
10
+ keeps the existing fall-back-to-shell behavior.
11
+ - Add `--export-config [output.json]`, which writes this entire `package.json`
12
+ to `buninu.json` in the current directory by default (like `--export`, the
13
+ output path can be overridden), instead of exporting the whole installation
14
+ like `--export` does. If the output path already exists, it now asks for
15
+ confirmation (`[y/N]`) before overwriting.
16
+
17
+ ## 0.2.2 - 2026-08-17
18
+
19
+ ### Added
20
+
21
+ - Add a "Commands inside the shell" section to README.md, split out of
22
+ Command-line information (which is only `bin/init.js`'s own flags, resolved
23
+ before Buninu starts). Lists everything in `apps/cmdlist` and moves the
24
+ `buninu-help`/`bunx` write-ups there, including a note that `bunx` can't
25
+ install anything on Android until
26
+ [oven-sh/bun#39084](https://github.com/oven-sh/bun/pull/39084) merges
27
+ upstream (Android's seccomp policy kills `bun i -g` with SIGSYS during bin
28
+ linking until then).
29
+
30
+ ## 0.2.1 - 2026-08-17
31
+
32
+ ### Added
33
+
34
+ - Add `bunx`, a POSIX and Windows multicall command that ensures a package is
35
+ installed via `bun i -g` and then runs its matching binary, forwarding the
36
+ remaining arguments. Reinstall timing is adapted from real bunx's own rules
37
+ (an explicit dist-tag like `@latest` always reinstalls; otherwise a cached
38
+ binary is reused until it's older than 24h, or stat fails, or bun's own
39
+ `install/global/package.json` shows a different pinned version than the one
40
+ requested — this script has one shared install location instead of bunx's
41
+ per-version cache, so this last check substitutes for that). The global bin
42
+ directory is resolved via `bun pm bin -g`, falling back to
43
+ `$BUN_INSTALL`/`$HOME/.bun` on the very first install before that project
44
+ exists (which `bun pm bin -g` requires).
45
+ - Runs the target through `bun <target>` instead of exec'ing it directly:
46
+ on Android the resolved binary typically sits under storage mounted
47
+ noexec, so a direct exec is refused even though the file is readable and
48
+ executable-bit set. Trade-off: a package whose bin is a real native
49
+ executable rather than a JS/bun script won't run this way.
50
+ - On Android (detected the same way as `bin/init.js`, via
51
+ `/system/bin/linker64`), installs with `--backend=copyfile` since
52
+ hardlinks/symlinks frequently fail across Android's storage, and sets
53
+ `$PREFIX=/data/data/com.termux/files/usr` for the target process (not the
54
+ install step) so CLIs that check for a Termux environment still find one,
55
+ unless `$PREFIX` is already set.
56
+
3
57
  ## 0.2.0 - 2026-08-17
4
58
 
5
59
  ### Added
package/README.md CHANGED
@@ -109,18 +109,41 @@ 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
 
119
142
  Export the current Buninu installation as a gzip-compressed tar archive:
120
143
 
121
144
  ```sh
122
- npx buninu --export
123
- npx buninu --export /path/to/buninu.tgz
145
+ npx buninu@latest --export
146
+ npx buninu@latest --export /path/to/buninu.tgz
124
147
  ```
125
148
 
126
149
  The default output is `./buninu.tgz`. The archive contains exactly one
@@ -128,6 +151,17 @@ top-level directory so consumers can remove one component while extracting.
128
151
  Export requires `tar` in `PATH`; when replacing an existing output,
129
152
  Buninu restores the previous file if archive creation or replacement fails.
130
153
 
154
+ Export this `package.json` on its own, instead of the whole installation:
155
+
156
+ ```sh
157
+ npx buninu@latest --export-config
158
+ npx buninu@latest --export-config /path/to/buninu.json
159
+ ```
160
+
161
+ The default output is `./buninu.json`. This is the full `package.json` (not
162
+ just the `buninu` section), so the output is ready to use as-is anywhere a
163
+ complete replacement `package.json` is expected.
164
+
131
165
  ## Environment
132
166
 
133
167
  `BUNINU_HOME` is the absolute path to the installed Buninu package root.
@@ -240,6 +274,19 @@ Override it for one run with:
240
274
  bun ./bin/init.js --command "echo temporary command"
241
275
  ```
242
276
 
277
+ Set `buninu.exitAfterCmd` to `true` to exit once `buninu.command` finishes
278
+ instead of falling back to an interactive shell. It defaults to `false`,
279
+ which is the current fall-back-to-shell behavior described above.
280
+
281
+ ```json
282
+ {
283
+ "buninu": {
284
+ "command": { "default": "echo Welcome to Buninu" },
285
+ "exitAfterCmd": true
286
+ }
287
+ }
288
+ ```
289
+
243
290
  ## Shell selection (optional)
244
291
 
245
292
  Set `buninu.shell.default` or a platform-specific value (`android`, `linux`,
@@ -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/bin/init.js CHANGED
@@ -4,6 +4,7 @@ import pkg from "../package.json" with { type: "json" };
4
4
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync } from "node:fs";
5
5
  import { tmpdir as systemTmpDir } from "node:os";
6
6
  import { basename, delimiter, dirname, isAbsolute, resolve } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { fileURLToPath } from "node:url";
8
9
 
9
10
  const binDir = dirname(fileURLToPath(import.meta.url));
@@ -27,6 +28,7 @@ Init options:
27
28
  --readme Render README.md in the terminal and exit
28
29
  --changelog Render CHANGELOG.md in the terminal and exit
29
30
  --export [output.tgz] Export this Buninu installation (default: ./buninu.tgz)
31
+ --export-config [output.json] Export this package.json (default: ./buninu.json)
30
32
  --shell <path|name> Override buninu.shell for this run
31
33
  --command <command> Override buninu.command for this run
32
34
 
@@ -42,10 +44,13 @@ Package configuration:
42
44
  package.json contains a buninu section for default and platform settings.
43
45
  buninu.shell Select the shell to start
44
46
  buninu.command Run a startup command before entering the shell
47
+ buninu.exitAfterCmd Exit after buninu.command instead of falling back to
48
+ the shell (default: false)
45
49
 
46
50
  All other options are forwarded to jsgotty. Without buninu.command, the
47
51
  selected shell starts directly. With a command, it runs from the package.json
48
- directory and then returns to the interactive shell regardless of exit status.
52
+ directory and then returns to the interactive shell regardless of exit status,
53
+ unless buninu.exitAfterCmd is true, in which case it exits instead.
49
54
  `;
50
55
  }
51
56
 
@@ -69,6 +74,12 @@ async function handleInformationArguments(arguments_) {
69
74
  return true;
70
75
  }
71
76
 
77
+ const exportConfigOption = readExportConfigArgument(arguments_);
78
+ if (exportConfigOption) {
79
+ await exportConfig(exportConfigOption.output);
80
+ return true;
81
+ }
82
+
72
83
  if (arguments_.includes("--readme")) {
73
84
  const readmePath = resolve(REPO_ROOT, "README.md");
74
85
  if (!await pathExists(readmePath)) fail(`README not found: ${readmePath}`);
@@ -106,6 +117,22 @@ function readExportArgument(arguments_) {
106
117
  return null;
107
118
  }
108
119
 
120
+ function readExportConfigArgument(arguments_) {
121
+ for (let index = 0; index < arguments_.length; index += 1) {
122
+ const argument = arguments_[index];
123
+ if (argument === "--export-config") {
124
+ const next = arguments_[index + 1];
125
+ return { output: next || "buninu.json" };
126
+ }
127
+ if (argument.startsWith("--export-config=")) {
128
+ const output = argument.slice("--export-config=".length);
129
+ if (!output) fail("--export-config= requires an output path");
130
+ return { output };
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
109
136
  async function exportInstallation(output) {
110
137
  const outputPath = resolve(process.cwd(), output);
111
138
  const tar = Bun.which(process.platform === "win32" ? "tar.exe" : "tar") || Bun.which("tar");
@@ -145,6 +172,25 @@ async function exportInstallation(output) {
145
172
  console.log(outputPath);
146
173
  }
147
174
 
175
+ async function exportConfig(output) {
176
+ const outputPath = resolve(process.cwd(), output);
177
+
178
+ if (existsSync(outputPath)) {
179
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
180
+ let answer;
181
+ try {
182
+ answer = await rl.question(`${outputPath} already exists and may be overwritten. Continue? [y/N] `);
183
+ } finally {
184
+ rl.close();
185
+ }
186
+ if (!/^[yY]/.test(answer.trim())) fail("Cancelled: output file was not overwritten");
187
+ }
188
+
189
+ const packageJsonPath = resolve(REPO_ROOT, "package.json");
190
+ await Bun.write(outputPath, Bun.file(packageJsonPath));
191
+ console.log(outputPath);
192
+ }
193
+
148
194
  function splitCommand(command) {
149
195
  const words = [];
150
196
  let word = "";
@@ -460,16 +506,25 @@ function readInitArguments(arguments_) {
460
506
  return { shell, command, forwarded };
461
507
  }
462
508
 
463
- function shellCommandArguments(shell, command) {
509
+ function shellCommandArguments(shell, command, exitAfterCmd) {
464
510
  if (!command) return [shell];
465
511
 
466
512
  const shellName = basename(shell).toLowerCase();
467
513
  if (shellName === "cmd" || shellName === "cmd.exe") {
514
+ if (exitAfterCmd) return [shell, "/d", "/s", "/c", command];
468
515
  const quotedShell = `"${shell.replaceAll('"', '""')}"`;
469
516
  return [shell, "/d", "/s", "/c", `${command} & ${quotedShell}`];
470
517
  }
471
518
 
472
519
  if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(shellName)) {
520
+ if (exitAfterCmd) {
521
+ return [
522
+ shell,
523
+ "-NoLogo",
524
+ "-Command",
525
+ `& { ${command}; if (-not $?) { Write-Error 'Startup command failed' } }`,
526
+ ];
527
+ }
473
528
  const escapedShell = shell.replaceAll("'", "''");
474
529
  return [
475
530
  shell,
@@ -483,7 +538,7 @@ function shellCommandArguments(shell, command) {
483
538
  `buninu_command=$1; shift; ( eval "$buninu_command" ); status=$?; ` +
484
539
  `if [ "$status" -ne 0 ]; then ` +
485
540
  `echo "buninu: startup command failed with exit $status" >&2; ` +
486
- `fi; exec "$0"`;
541
+ (exitAfterCmd ? `fi; exit "$status"` : `fi; exec "$0"`);
487
542
  // Pass both the shell and startup command as argv instead of interpolating
488
543
  // them into the wrapper. This keeps the wrapper single-line and avoids LF
489
544
  // (Ctrl+J) characters in its process-list representation.
@@ -539,6 +594,7 @@ const configuredCommand =
539
594
  commandConfiguration?.default ??
540
595
  null;
541
596
  const startupCommand = initArguments.command ?? configuredCommand;
597
+ const exitAfterCmd = Boolean(pkg.buninu?.exitAfterCmd);
542
598
 
543
599
  const forwardedArguments = initArguments.forwarded;
544
600
  const hasExplicitPort = forwardedArguments.some((argument) =>
@@ -559,7 +615,7 @@ const child = Bun.spawn(
559
615
  ...command,
560
616
  ...portArguments,
561
617
  ...forwardedArguments,
562
- ...shellCommandArguments(environment.shell, startupCommand),
618
+ ...shellCommandArguments(environment.shell, startupCommand, exitAfterCmd),
563
619
  ],
564
620
  {
565
621
  cwd: rootDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
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",
@@ -22,7 +22,8 @@
22
22
  "linux": null,
23
23
  "macos": null,
24
24
  "windows": null
25
- }
25
+ },
26
+ "exitAfterCmd": false
26
27
  },
27
28
  "scripts": {
28
29
  "start": "bun ./apps/jsgotty/gotty.js --reconnect -r -w --webgl -a 127.0.0.1",