@bigconfig/bb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +73 -0
  2. package/bin/bb.js +408 -0
  3. package/package.json +24 -0
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @bigconfig/bb
2
+
3
+ Run [babashka](https://babashka.org) (`bb`) without installing anything first.
4
+ On its first invocation this package downloads a pinned babashka binary **and**
5
+ an Eclipse Temurin JDK into a shared user cache, then forwards every argument
6
+ to `bb`.
7
+
8
+ ## Usage
9
+
10
+ ```sh
11
+ npx @bigconfig/bb tasks # -> bb tasks
12
+ npx @bigconfig/bb <args...> # -> bb <args...>
13
+ ```
14
+
15
+ All arguments (including flags) are passed through verbatim, and `bb` runs in
16
+ your current working directory, so it picks up the local `bb.edn`.
17
+
18
+ ## What happens on first run
19
+
20
+ 1. The host OS/CPU are resolved to the matching babashka release asset and
21
+ Adoptium API parameters.
22
+ 2. babashka is downloaded from its GitHub releases and cached.
23
+ 3. A Temurin JDK is downloaded from the Adoptium API and cached.
24
+ 4. `bb` is launched with `JAVA_HOME` / `PATH` pointing at the cached JDK — the
25
+ environment change applies **only** to the `bb` subprocess, nothing
26
+ system-wide.
27
+
28
+ Subsequent runs reuse the cache and start immediately.
29
+
30
+ ## git (Linux only)
31
+
32
+ On Linux, if `git` is not on `PATH`, it is installed via the system package
33
+ manager (`apt-get`, `dnf`, `yum`, `zypper`, `pacman`, or `apk`), using `sudo`
34
+ when not running as root. This is **skipped** when git is already present, and
35
+ is a **no-op on macOS/Windows**. Unlike babashka/JDK, this modifies the system
36
+ and may prompt for a sudo password; in non-interactive environments without
37
+ passwordless sudo it will fail with an actionable message — pre-install git to
38
+ avoid this entirely.
39
+
40
+ ## Cache location
41
+
42
+ A single shared directory, reused across all projects:
43
+
44
+ | Platform | Path |
45
+ | ------------- | -------------------------------------------- |
46
+ | macOS / Linux | `$XDG_CACHE_HOME` or `~/.cache` → `bigconfig-bb/` |
47
+ | Windows | `%LOCALAPPDATA%` → `bigconfig-bb/` |
48
+
49
+ Delete that directory to force a clean reinstall.
50
+
51
+ ## Configuration
52
+
53
+ | Env var | Default | Effect |
54
+ | ------------- | ---------- | --------------------------------------- |
55
+ | `BB_VERSION` | `1.12.196` | babashka release version to install |
56
+ | `JDK_VERSION` | `21` | Temurin feature version (e.g. `17`, `21`, `25`) |
57
+
58
+ ## Supported platforms
59
+
60
+ macOS arm64, macOS x64, Linux x64, Linux arm64, Windows x64.
61
+
62
+ Notes:
63
+
64
+ - Linux x64 uses babashka's glibc build (may not run on musl distros such as
65
+ Alpine). Linux arm64 uses babashka's static build, which runs on both glibc
66
+ and musl.
67
+ - Extraction uses the system `tar` (present on macOS, Linux, and Windows
68
+ 10+); Windows falls back to PowerShell `Expand-Archive` for `.zip` if `tar`
69
+ is unavailable.
70
+
71
+ ## Requirements
72
+
73
+ Node.js >= 18.
package/bin/bb.js ADDED
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // @bigconfig/bb — bootstraps babashka + a Temurin JDK on first use, then
5
+ // forwards all arguments to `bb`. Single-file launcher, no build step.
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawn, spawnSync } = require('child_process');
11
+ const { Readable } = require('stream');
12
+ const { pipeline } = require('stream/promises');
13
+
14
+ // Pinned, known-good versions. Overridable via env.
15
+ const DEFAULT_BB_VERSION = process.env.BB_VERSION || '1.12.196';
16
+ const DEFAULT_JDK_VERSION = process.env.JDK_VERSION || '21';
17
+
18
+ const TAG = '[@bigconfig/bb]';
19
+
20
+ function log(msg) {
21
+ // stderr so stdout stays clean for bb's own output.
22
+ process.stderr.write(`${TAG} ${msg}\n`);
23
+ }
24
+
25
+ // --- Platform resolution -------------------------------------------------
26
+
27
+ // Maps the host OS/arch to babashka release asset + Adoptium API parameters.
28
+ function resolvePlatform() {
29
+ const plat = process.platform; // 'darwin' | 'linux' | 'win32'
30
+ const arch = process.arch; // 'arm64' | 'x64'
31
+ const exeSuffix = plat === 'win32' ? '.exe' : '';
32
+
33
+ let bbOs;
34
+ let jdkOs;
35
+ let archiveExt;
36
+ if (plat === 'darwin') {
37
+ bbOs = 'macos';
38
+ jdkOs = 'mac';
39
+ archiveExt = 'tar.gz';
40
+ } else if (plat === 'linux') {
41
+ bbOs = 'linux';
42
+ jdkOs = 'linux';
43
+ archiveExt = 'tar.gz';
44
+ } else if (plat === 'win32') {
45
+ bbOs = 'windows';
46
+ jdkOs = 'windows';
47
+ archiveExt = 'zip';
48
+ } else {
49
+ throw new Error(`Unsupported OS: ${plat}`);
50
+ }
51
+
52
+ let bbArch;
53
+ let jdkArch;
54
+ if (arch === 'arm64') {
55
+ bbArch = 'aarch64';
56
+ jdkArch = 'aarch64';
57
+ } else if (arch === 'x64') {
58
+ bbArch = 'amd64';
59
+ jdkArch = 'x64';
60
+ } else {
61
+ throw new Error(`Unsupported CPU architecture: ${arch}`);
62
+ }
63
+
64
+ // babashka ships only a *static* (musl) build for Linux arm64 — there is no
65
+ // dynamic linux-aarch64 asset. The static build also runs on glibc.
66
+ const bbArchToken =
67
+ plat === 'linux' && arch === 'arm64' ? 'aarch64-static' : bbArch;
68
+
69
+ if (plat === 'win32' && arch === 'arm64') {
70
+ throw new Error('babashka has no prebuilt Windows arm64 binary');
71
+ }
72
+
73
+ return {
74
+ exeSuffix,
75
+ archiveExt,
76
+ jdkOs,
77
+ jdkArch,
78
+ bbAssetName(version) {
79
+ return `babashka-${version}-${bbOs}-${bbArchToken}.${archiveExt}`;
80
+ },
81
+ jdkArchiveName: `jdk.${archiveExt}`,
82
+ };
83
+ }
84
+
85
+ function cacheRoot() {
86
+ const base =
87
+ process.platform === 'win32'
88
+ ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
89
+ : process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
90
+ return path.join(base, 'bigconfig-bb');
91
+ }
92
+
93
+ // --- Filesystem helpers --------------------------------------------------
94
+
95
+ function rmrf(target) {
96
+ fs.rmSync(target, { recursive: true, force: true });
97
+ }
98
+
99
+ // Runs `install(tmpDir)` into a private temp dir, then atomically renames it
100
+ // into place. Concurrent first-runs race on the rename; the loser is discarded
101
+ // so the final dir is never left half-written.
102
+ async function installOnce(finalDir, install) {
103
+ if (fs.existsSync(finalDir)) return;
104
+ const tmp = `${finalDir}.tmp-${process.pid}-${Date.now()}`;
105
+ fs.mkdirSync(tmp, { recursive: true });
106
+ try {
107
+ await install(tmp);
108
+ if (fs.existsSync(finalDir)) {
109
+ rmrf(tmp); // another process won the race
110
+ return;
111
+ }
112
+ fs.mkdirSync(path.dirname(finalDir), { recursive: true });
113
+ try {
114
+ fs.renameSync(tmp, finalDir);
115
+ } catch (err) {
116
+ if (fs.existsSync(finalDir)) {
117
+ rmrf(tmp);
118
+ return;
119
+ }
120
+ throw err;
121
+ }
122
+ } catch (err) {
123
+ rmrf(tmp);
124
+ throw err;
125
+ }
126
+ }
127
+
128
+ async function download(url, destFile) {
129
+ const res = await fetch(url, {
130
+ redirect: 'follow',
131
+ headers: { 'user-agent': '@bigconfig/bb' },
132
+ });
133
+ if (!res.ok || !res.body) {
134
+ throw new Error(
135
+ `Download failed (HTTP ${res.status} ${res.statusText})\n ${url}`
136
+ );
137
+ }
138
+ await fs.promises.mkdir(path.dirname(destFile), { recursive: true });
139
+ await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destFile));
140
+ }
141
+
142
+ // Extracts .tar.gz / .zip. System `tar` (GNU on Linux, bsdtar on macOS &
143
+ // Windows 10+) auto-detects gzip and handles zip; PowerShell is a Windows
144
+ // fallback when `tar` is absent.
145
+ function extract(archive, destDir) {
146
+ fs.mkdirSync(destDir, { recursive: true });
147
+ let r = spawnSync('tar', ['-xf', archive, '-C', destDir], {
148
+ stdio: ['ignore', 'inherit', 'inherit'],
149
+ });
150
+ if (r.error && r.error.code === 'ENOENT') {
151
+ if (process.platform === 'win32' && archive.endsWith('.zip')) {
152
+ r = spawnSync(
153
+ 'powershell',
154
+ [
155
+ '-NoProfile',
156
+ '-Command',
157
+ `Expand-Archive -LiteralPath '${archive}' -DestinationPath '${destDir}' -Force`,
158
+ ],
159
+ { stdio: ['ignore', 'inherit', 'inherit'] }
160
+ );
161
+ if (r.status !== 0) {
162
+ throw new Error(`Failed to extract ${archive} (PowerShell fallback)`);
163
+ }
164
+ return;
165
+ }
166
+ throw new Error(`'tar' not found on PATH; cannot extract ${archive}`);
167
+ }
168
+ if (r.status !== 0) {
169
+ throw new Error(`Failed to extract ${archive} (tar exit ${r.status})`);
170
+ }
171
+ }
172
+
173
+ // Locates JAVA_HOME inside an extracted JDK. Layout differs per OS
174
+ // (linux: <root>/bin/java, macOS: <root>/Contents/Home/bin/java).
175
+ function findJavaHome(root, exeSuffix) {
176
+ const javaRel = path.join('bin', `java${exeSuffix}`);
177
+ const stack = [root];
178
+ while (stack.length) {
179
+ const dir = stack.pop();
180
+ if (fs.existsSync(path.join(dir, javaRel))) return dir;
181
+ let entries;
182
+ try {
183
+ entries = fs.readdirSync(dir, { withFileTypes: true });
184
+ } catch {
185
+ continue;
186
+ }
187
+ for (const e of entries) {
188
+ if (e.isDirectory()) stack.push(path.join(dir, e.name));
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // --- Bootstrap steps -----------------------------------------------------
195
+
196
+ async function ensureBabashka(p) {
197
+ const version = DEFAULT_BB_VERSION;
198
+ const finalDir = path.join(cacheRoot(), 'bb', version);
199
+ const bbPath = path.join(finalDir, `bb${p.exeSuffix}`);
200
+ const asset = p.bbAssetName(version);
201
+
202
+ await installOnce(finalDir, async (tmp) => {
203
+ const archive = path.join(tmp, asset);
204
+ const url = `https://github.com/babashka/babashka/releases/download/v${version}/${asset}`;
205
+ log(`Installing babashka ${version} (set BB_VERSION to override)...`);
206
+ try {
207
+ await download(url, archive);
208
+ } catch (err) {
209
+ throw new Error(`${err.message}\n (override with BB_VERSION=<version>)`);
210
+ }
211
+ extract(archive, tmp);
212
+ fs.unlinkSync(archive);
213
+ const exe = path.join(tmp, `bb${p.exeSuffix}`);
214
+ if (!fs.existsSync(exe)) {
215
+ throw new Error('babashka binary not found after extraction');
216
+ }
217
+ if (process.platform !== 'win32') fs.chmodSync(exe, 0o755);
218
+ });
219
+
220
+ if (!fs.existsSync(bbPath)) {
221
+ throw new Error(
222
+ `babashka cache looks corrupt; remove ${finalDir} and retry`
223
+ );
224
+ }
225
+ if (process.platform !== 'win32') {
226
+ try {
227
+ fs.chmodSync(bbPath, 0o755);
228
+ } catch {
229
+ /* already executable */
230
+ }
231
+ }
232
+ return bbPath;
233
+ }
234
+
235
+ async function ensureJdk(p) {
236
+ const feature = DEFAULT_JDK_VERSION;
237
+ const finalDir = path.join(cacheRoot(), 'jdk', feature);
238
+ const marker = path.join(finalDir, '.javahome');
239
+
240
+ await installOnce(finalDir, async (tmp) => {
241
+ const archive = path.join(tmp, p.jdkArchiveName);
242
+ const url =
243
+ `https://api.adoptium.net/v3/binary/latest/${feature}/ga/` +
244
+ `${p.jdkOs}/${p.jdkArch}/jdk/hotspot/normal/eclipse`;
245
+ log(`Installing Temurin JDK ${feature} (set JDK_VERSION to override)...`);
246
+ try {
247
+ await download(url, archive);
248
+ } catch (err) {
249
+ throw new Error(`${err.message}\n (override with JDK_VERSION=<feature>)`);
250
+ }
251
+ extract(archive, tmp);
252
+ fs.unlinkSync(archive);
253
+ const home = findJavaHome(tmp, p.exeSuffix);
254
+ if (!home) throw new Error('could not locate java in extracted JDK');
255
+ // Path relative to tmp stays valid after tmp is renamed to finalDir.
256
+ fs.writeFileSync(path.join(tmp, '.javahome'), path.relative(tmp, home));
257
+ });
258
+
259
+ let javaHome = null;
260
+ try {
261
+ javaHome = path.join(finalDir, fs.readFileSync(marker, 'utf8').trim());
262
+ } catch {
263
+ javaHome = null;
264
+ }
265
+ if (
266
+ !javaHome ||
267
+ !fs.existsSync(path.join(javaHome, 'bin', `java${p.exeSuffix}`))
268
+ ) {
269
+ javaHome = findJavaHome(finalDir, p.exeSuffix);
270
+ }
271
+ if (!javaHome) {
272
+ throw new Error(`JDK cache looks corrupt; remove ${finalDir} and retry`);
273
+ }
274
+ return javaHome;
275
+ }
276
+
277
+ // --- git (Linux only) ----------------------------------------------------
278
+
279
+ function commandWorks(cmd, args) {
280
+ const r = spawnSync(cmd, args, { stdio: 'ignore' });
281
+ return !r.error && r.status === 0;
282
+ }
283
+
284
+ // ENOENT sets r.error; any exit code otherwise means the binary exists.
285
+ function binExists(cmd) {
286
+ return !spawnSync(cmd, ['--version'], { stdio: 'ignore' }).error;
287
+ }
288
+
289
+ // On Linux, install git via the system package manager if it is missing.
290
+ // Skipped when git is already on PATH; a no-op on macOS/Windows.
291
+ function ensureGit() {
292
+ if (process.platform !== 'linux') return;
293
+ if (commandWorks('git', ['--version'])) return;
294
+
295
+ const isRoot =
296
+ typeof process.getuid === 'function' && process.getuid() === 0;
297
+ const sudo = isRoot ? [] : binExists('sudo') ? ['sudo'] : null;
298
+ if (sudo === null) {
299
+ throw new Error(
300
+ 'git is missing and cannot be installed: not running as root and ' +
301
+ '`sudo` is unavailable.\n' +
302
+ ' Install git manually (e.g. `apt-get install git`) and re-run.'
303
+ );
304
+ }
305
+
306
+ // First match wins; `soft` lists step indexes allowed to fail (e.g.
307
+ // `apt-get update`, which is non-fatal if package lists already exist).
308
+ const managers = [
309
+ {
310
+ bin: 'apt-get',
311
+ steps: [
312
+ ['apt-get', 'update', '-y'],
313
+ ['apt-get', 'install', '-y', 'git'],
314
+ ],
315
+ soft: [0],
316
+ },
317
+ { bin: 'dnf', steps: [['dnf', 'install', '-y', 'git']] },
318
+ { bin: 'yum', steps: [['yum', 'install', '-y', 'git']] },
319
+ {
320
+ bin: 'zypper',
321
+ steps: [['zypper', '--non-interactive', 'install', 'git']],
322
+ },
323
+ { bin: 'pacman', steps: [['pacman', '-S', '--noconfirm', 'git']] },
324
+ { bin: 'apk', steps: [['apk', 'add', '--no-cache', 'git']] },
325
+ ];
326
+ const pm = managers.find((m) => binExists(m.bin));
327
+ if (!pm) {
328
+ throw new Error(
329
+ 'git is missing and no supported package manager ' +
330
+ '(apt-get/dnf/yum/zypper/pacman/apk) was found.\n' +
331
+ ' Install git manually and re-run.'
332
+ );
333
+ }
334
+
335
+ log(`Installing git via ${pm.bin}${sudo.length ? ' (sudo)' : ''}...`);
336
+ const env = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
337
+ pm.steps.forEach((step, i) => {
338
+ const argv = [...sudo, ...step];
339
+ const r = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', env });
340
+ const ok = !r.error && r.status === 0;
341
+ if (!ok && !(pm.soft && pm.soft.includes(i))) {
342
+ const why = r.error ? r.error.code : `exit ${r.status}`;
343
+ throw new Error(`git install failed: \`${argv.join(' ')}\` (${why}).`);
344
+ }
345
+ });
346
+
347
+ if (!commandWorks('git', ['--version'])) {
348
+ throw new Error('git still not available after the install attempt.');
349
+ }
350
+ }
351
+
352
+ // --- Run bb --------------------------------------------------------------
353
+
354
+ function runBb(bbPath, args, javaHome) {
355
+ const env = { ...process.env };
356
+ if (javaHome) {
357
+ env.JAVA_HOME = javaHome;
358
+ env.PATH =
359
+ path.join(javaHome, 'bin') +
360
+ path.delimiter +
361
+ (process.env.PATH || '');
362
+ }
363
+
364
+ const child = spawn(bbPath, args, {
365
+ stdio: 'inherit',
366
+ cwd: process.cwd(),
367
+ env,
368
+ });
369
+
370
+ const forward = (sig) => {
371
+ try {
372
+ child.kill(sig);
373
+ } catch {
374
+ /* child already gone */
375
+ }
376
+ };
377
+ process.on('SIGINT', forward);
378
+ process.on('SIGTERM', forward);
379
+
380
+ return new Promise((resolve) => {
381
+ child.on('error', (err) => {
382
+ log(`failed to start bb: ${err.message}`);
383
+ resolve(127);
384
+ });
385
+ child.on('exit', (code, signal) => {
386
+ resolve(signal ? 1 : code == null ? 1 : code);
387
+ });
388
+ });
389
+ }
390
+
391
+ async function main(args) {
392
+ const p = resolvePlatform();
393
+ const bbPath = await ensureBabashka(p);
394
+ const javaHome = await ensureJdk(p);
395
+ ensureGit();
396
+ const code = await runBb(bbPath, args, javaHome);
397
+ process.exit(code);
398
+ }
399
+
400
+ if (require.main === module) {
401
+ main(process.argv.slice(2)).catch((err) => {
402
+ log(err && err.message ? err.message : String(err));
403
+ process.exit(1);
404
+ });
405
+ }
406
+
407
+ // Exported for tests / inspection.
408
+ module.exports = { resolvePlatform, cacheRoot, findJavaHome, ensureGit };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@bigconfig/bb",
3
+ "version": "0.1.0",
4
+ "description": "Bootstrap and run babashka (bb): installs babashka and a Temurin JDK on first use if missing, then forwards all arguments to bb.",
5
+ "bin": {
6
+ "bb": "bin/bb.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "README.md"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "babashka",
17
+ "bb",
18
+ "clojure",
19
+ "jdk",
20
+ "cli"
21
+ ],
22
+ "license": "MIT",
23
+ "type": "commonjs"
24
+ }