bc-pkg 1.0.0 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +41 -139
  2. package/bin/bc-pkg.js +445 -421
  3. package/package.json +13 -13
package/README.md CHANGED
@@ -1,163 +1,65 @@
1
1
  # bc-pkg
2
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`.
3
+ `bc-pkg` creates or reuses a BigConfig CLI in the current directory, then
4
+ forwards your command to that CLI.
5
+
6
+ The npm package and the PyPI package expose the same behavior. The target
7
+ package can be implemented in Clojure, TypeScript, or Python; `bc-pkg` infers
8
+ that language from the pinned GitHub content.
7
9
 
8
10
  ## Usage
9
11
 
10
12
  ```sh
11
- npx bc-pkg@latest tasks # -> bb tasks
12
- npx bc-pkg@latest <args...> # -> bb <args...>
13
- npx bc-pkg@latest <owner>/<project> <args...> # bootstrap/validate bb.edn, then -> bb <args...>
13
+ npx bc-pkg <owner/repo@ref> package validate
14
+ npx bc-pkg package validate
14
15
  ```
15
16
 
16
- The npm package is unscoped `bc-pkg` and exposes a single `bc-pkg` bin. All
17
- other arguments (including flags) are passed through verbatim, and `bb` runs in
18
- your current working directory, so it picks up the local `bb.edn`. If
19
- the first argument has the shape `owner/project`, it is consumed as repo
20
- identity and never forwarded to `bb`: it bootstraps a missing `bb.edn` or
21
- validates the existing `bb.edn`'s top-level `:repo`.
22
-
23
- ## What happens on first run
24
-
25
- 1. The host OS/CPU are resolved to the matching babashka release asset and
26
- Adoptium API parameters.
27
- 2. babashka is downloaded from its GitHub releases and cached.
28
- 3. A Temurin JDK is downloaded from the Adoptium API and cached.
29
- 4. On Linux, `git` is installed via the system package manager if it is not on
30
- `PATH`.
31
- 5. If a `<owner>/<project>` slug is the first argument, it is consumed; it
32
- bootstraps a missing `bb.edn` or validates an existing `bb.edn`'s `:repo`.
33
- 6. `bb` is launched with `JAVA_HOME` / `PATH` pointing at the cached JDK (and
34
- the cached `bb`, so nested `bb` calls work) — the environment change applies
35
- **only** to the `bb` subprocess.
36
-
37
- Subsequent runs reuse the cache and start immediately.
38
-
39
- ## git (Linux only)
40
-
41
- On Linux, if `git` is not on `PATH`, it is installed via the system package
42
- manager (`apt-get`, `dnf`, `yum`, `zypper`, `pacman`, or `apk`), using `sudo`
43
- when not running as root. This is **skipped** when git is already present, and
44
- is a **no-op on macOS/Windows**. Unlike babashka/JDK, this modifies the system
45
- and may prompt for a sudo password; in non-interactive environments without
46
- passwordless sudo it will fail with an actionable message — pre-install git to
47
- avoid this entirely.
48
-
49
- ## bb.edn bootstrap (optional)
50
-
51
- If the current directory has **no `bb.edn`** and the first argument has the
52
- shape `owner/project`, that repo's `bb.edn` is downloaded (pinned to its
53
- default branch's latest commit), top-level `:repo "owner/project"` is ensured,
54
- and the repo itself is added to `:deps` as
55
- `io.github.<owner>/<project> {:git/sha "<sha>"}`. The edit is done with
56
- `borkdude/rewrite-edn`, so existing comments and formatting are preserved.
57
- The slug is consumed; remaining arguments are forwarded to `bb`.
58
-
59
- If a `bb.edn` already exists, the same slug is still consumed and compared
60
- exactly with top-level `:repo`. The slug may be omitted when `bb.edn` exists.
61
-
62
- Any dependency using `:local/root` (in `:deps` or a task's `:extra-deps`) is
63
- removed first, since those paths don't exist once the file is downloaded.
64
- Valid Maven/git deps are kept.
65
-
66
- - Skipped if no slug is given and no `bb.edn` needs to be created.
67
- - Fatal error, when a slug is supplied, if an existing `bb.edn` is invalid,
68
- lacks `:repo`, or has a different `:repo`.
69
- - Fatal error if the repo is missing/inaccessible, has no `bb.edn`, or its
70
- `bb.edn` declares a different `:repo`.
71
- - Set `GITHUB_TOKEN` for private repos or to avoid GitHub's unauthenticated
72
- API rate limit.
17
+ `ref` can be a branch name or a full 40-character commit SHA:
73
18
 
74
19
  ```sh
75
- npx bc-pkg@latest my-org/shared-tasks tasks
20
+ npx bc-pkg bigconfig-ai/once@typescript package validate
21
+ npx bc-pkg bigconfig-ai/once@2f4e8c0d0b4c4b8f0c3a9f6e2a1b5c7d8e9f0123 package validate
76
22
  ```
77
23
 
78
- ## Cache location
79
-
80
- A single shared directory, reused across all projects:
81
-
82
- | Platform | Path |
83
- | ------------- | -------------------------------------------- |
84
- | macOS / Linux | `$XDG_CACHE_HOME` or `~/.cache` → `bc-pkg/` |
85
- | Windows | `%LOCALAPPDATA%` → `bc-pkg/` |
86
-
87
- Delete that directory to force a clean reinstall.
88
-
89
- ## Configuration
90
-
91
- | Env var | Default | Effect |
92
- | --------------------- | ---------- | ----------------------------------------------- |
93
- | `BB_VERSION` | `1.12.196` | babashka release version to install |
94
- | `JDK_VERSION` | `21` | Temurin feature version (e.g. `17`, `21`, `25`) |
95
- | `GITHUB_TOKEN` | _(unset)_ | Used for the bb.edn bootstrap (private repos / higher API rate limit) |
96
- | `REWRITE_EDN_VERSION` | `0.5.9` | `borkdude/rewrite-edn` version used to edit the `bb.edn` |
24
+ On the first run, `bc-pkg` resolves the ref to a full SHA and pins it. Later
25
+ runs omit `<owner/repo@ref>` and keep using the pinned SHA.
97
26
 
98
- ## Supported platforms
27
+ ## What is created
99
28
 
100
- macOS arm64, macOS x64, Linux x64, Linux arm64, Windows x64.
29
+ The launcher copies the target package's root `run` file into the current
30
+ directory and writes language-native metadata:
101
31
 
102
- Notes:
32
+ | Target language | Manifest | Runtime command |
33
+ | --- | --- | --- |
34
+ | Clojure | `deps.edn` | `bb run ...` |
35
+ | TypeScript | `package.json` | `node run ...` |
36
+ | Python | `pyproject.toml` | `uv run python run ...` |
103
37
 
104
- - Linux x64 uses babashka's glibc build (may not run on musl distros such as
105
- Alpine). Linux arm64 uses babashka's static build, which runs on both glibc
106
- and musl.
107
- - Extraction uses the system `tar` (present on macOS, Linux, and Windows
108
- 10+); Windows falls back to PowerShell `Expand-Archive` for `.zip` if `tar`
109
- is unavailable.
38
+ For Clojure targets, a small `bb.edn` runtime dependency file is also written so
39
+ Babashka can load the pinned Git dependency.
110
40
 
111
- ## Development Docker image
41
+ If the directory is already initialized for a different repo/ref/SHA, `bc-pkg`
42
+ exits with an error instead of updating it implicitly.
112
43
 
113
- This repository also includes a `Dockerfile` and `bb.edn` tasks for a
114
- throwaway development shell. The former Makefile workflow now lives in
115
- `bb.edn`. The image is based on Ubuntu 24.04 and installs Node.js, the pi
116
- coding agent, Claude, `ripgrep`, `fd`, and `sudo`. Requires Docker. Commands
117
- below assume `bb` is on `PATH`; use `node bin/bc-pkg.js <task>` to exercise the
118
- local launcher instead.
44
+ ## Requirements
119
45
 
120
- If these tasks are bootstrapped into an empty directory by passing
121
- `bigconfig-ai/bc-pkg` as the first argument, the missing `Dockerfile` is
122
- downloaded into that directory from the same pinned GitHub SHA as the
123
- bootstrapped `bb.edn`:
46
+ - npm launcher: Node.js >= 18.
47
+ - TypeScript target packages: Node.js and npm must already be installed.
48
+ - Python target packages: Python and `uv` must already be installed.
49
+ - Clojure target packages: `bc-pkg` downloads pinned Babashka and Temurin JDK
50
+ versions into a shared user cache and checks/installs `git` on Linux.
124
51
 
125
- ```sh
126
- mkdir empty && cd empty
127
- npx bc-pkg@latest bigconfig-ai/bc-pkg shell
128
- ```
52
+ ## Environment
129
53
 
130
- ```sh
131
- bb tasks # list repository tasks
132
- bb build # build bc-pkg:dev
133
- bb build --no-cache # rebuild without Docker layer cache
134
- bb shell # build, create a generated home, then open bash
135
- bb shell --skip-build # reuse the existing image
136
- ```
54
+ | Variable | Effect |
55
+ | --- | --- |
56
+ | `GITHUB_TOKEN` | Used for private GitHub repos or higher API rate limits. |
57
+ | `BB_VERSION` | Override the Babashka version for Clojure targets. |
58
+ | `JDK_VERSION` | Override the Temurin JDK feature version for Clojure targets. |
137
59
 
138
- `bb shell` creates a writable host directory under `homes/<random-name>` and
139
- mounts it at `/home/developer` in the container. Before starting Docker it
140
- copies `~/.pi/agent/auth.json` and `~/.pi/agent/settings.json` into that
141
- generated home when those files exist; missing files are skipped. Use
142
- `--project-subdir PATH` to mount a specific host directory instead, and
143
- `bb homes` / `bb clean --all` to list or remove generated homes.
144
-
145
- Common options are available as flags or environment variables:
146
-
147
- | Option / env | Default | Effect |
148
- | ------------ | ------- | ------ |
149
- | `--image` / `IMAGE` | `bc-pkg` | Docker image name |
150
- | `--tag` / `TAG` | `dev` | Docker image tag |
151
- | `--node-major` / `NODE_MAJOR` | `24` | Node.js major version build arg |
152
- | `--no-cache` | `false` | Pass `--no-cache` to `docker build` |
153
- | `--workdir` / `WORKDIR` | `/home/developer` | Container working directory |
154
- | `--name` / `DOCKER_STYLE_RANDOM_NAME` | random | Container hostname and generated home name |
155
- | `--project-subdir` / `PROJECT_SUBDIR` | `homes/<name>` | Host directory mounted into the container |
156
- | `--docker-run-arg ARG` | _(none)_ | Extra `docker run` argument; repeat as needed |
157
- | `--dry-run` | `false` | Print commands without executing them |
158
-
159
- Run `bb options` for the full option list.
60
+ ## Cache
160
61
 
161
- ## Requirements
62
+ Babashka and JDK downloads are shared across projects under:
162
63
 
163
- Node.js >= 18.
64
+ - macOS/Linux: `$XDG_CACHE_HOME/bc-pkg` or `~/.cache/bc-pkg`
65
+ - Windows: `%LOCALAPPDATA%/bc-pkg`
package/bin/bc-pkg.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // bc-pkg — bootstraps babashka + a Temurin JDK on first use, then
5
- // forwards all arguments to `bb`. Single-file launcher, no build step.
4
+ // bc-pkg — creates/reuses a BigConfig CLI in the current directory and runs it.
5
+ // The target package can be implemented in Clojure, TypeScript, or Python. The
6
+ // target language is inferred from the package's pinned GitHub content.
6
7
 
7
8
  const fs = require('fs');
8
9
  const os = require('os');
@@ -11,24 +12,403 @@ const { spawn, spawnSync } = require('child_process');
11
12
  const { Readable } = require('stream');
12
13
  const { pipeline } = require('stream/promises');
13
14
 
14
- // Pinned, known-good versions. Overridable via env.
15
15
  const DEFAULT_BB_VERSION = process.env.BB_VERSION || '1.12.196';
16
16
  const DEFAULT_JDK_VERSION = process.env.JDK_VERSION || '21';
17
- const REWRITE_EDN_VERSION = process.env.REWRITE_EDN_VERSION || '0.5.9';
18
-
19
17
  const TAG = '[bc-pkg]';
18
+ const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/;
19
+ const SPEC_RE = /^([^/\s@]+)\/([^/\s@]+)@([^\s]+)$/;
20
20
 
21
21
  function log(msg) {
22
- // stderr so stdout stays clean for bb's own output.
23
22
  process.stderr.write(`${TAG} ${msg}\n`);
24
23
  }
25
24
 
26
- // --- Platform resolution -------------------------------------------------
25
+ function fail(msg) {
26
+ throw new Error(msg);
27
+ }
28
+
29
+ function usage() {
30
+ return `Usage:\n bc-pkg <owner/repo@ref> <args...>\n bc-pkg <args...>\n\nExamples:\n npx bc-pkg bigconfig-ai/once@typescript package validate\n npx bc-pkg package validate`;
31
+ }
32
+
33
+ // --- generic process helpers -------------------------------------------
34
+
35
+ function commandWorks(cmd, args = ['--version']) {
36
+ const r = spawnSync(cmd, args, { stdio: 'ignore' });
37
+ return !r.error && r.status === 0;
38
+ }
39
+
40
+ function binExists(cmd) {
41
+ return !spawnSync(cmd, ['--version'], { stdio: 'ignore' }).error;
42
+ }
43
+
44
+ function runCommand(cmd, args, options = {}) {
45
+ const child = spawn(cmd, args, {
46
+ stdio: 'inherit',
47
+ cwd: process.cwd(),
48
+ env: process.env,
49
+ ...options,
50
+ });
51
+
52
+ const forward = (sig) => {
53
+ try {
54
+ child.kill(sig);
55
+ } catch {
56
+ // child already gone
57
+ }
58
+ };
59
+ process.on('SIGINT', forward);
60
+ process.on('SIGTERM', forward);
61
+
62
+ return new Promise((resolve) => {
63
+ child.on('error', (err) => {
64
+ log(`failed to start ${cmd}: ${err.message}`);
65
+ resolve(127);
66
+ });
67
+ child.on('exit', (code, signal) => {
68
+ resolve(signal ? 1 : code == null ? 1 : code);
69
+ });
70
+ });
71
+ }
72
+
73
+ function whichPython() {
74
+ if (commandWorks('python3')) return 'python3';
75
+ if (commandWorks('python')) return 'python';
76
+ return null;
77
+ }
78
+
79
+ function requireCommand(cmd, installHint) {
80
+ if (!commandWorks(cmd)) {
81
+ fail(`${cmd} is required but was not found on PATH.${installHint ? `\n ${installHint}` : ''}`);
82
+ }
83
+ }
84
+
85
+ // --- GitHub --------------------------------------------------------------
86
+
87
+ function parseSpec(arg) {
88
+ const m = arg && arg.match(SPEC_RE);
89
+ if (!m) return null;
90
+ return { owner: m[1], repo: m[2], ref: m[3], slug: `${m[1]}/${m[2]}` };
91
+ }
92
+
93
+ function ghHeaders(accept) {
94
+ const headers = {
95
+ 'user-agent': 'bc-pkg',
96
+ accept: accept || 'application/vnd.github+json',
97
+ 'x-github-api-version': '2022-11-28',
98
+ };
99
+ if (process.env.GITHUB_TOKEN) {
100
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
101
+ }
102
+ return headers;
103
+ }
104
+
105
+ async function ghFetch(url, accept) {
106
+ return fetch(url, { headers: ghHeaders(accept), redirect: 'follow' });
107
+ }
108
+
109
+ async function resolveRef(spec) {
110
+ if (FULL_SHA_RE.test(spec.ref)) {
111
+ return spec.ref.toLowerCase();
112
+ }
113
+ const url = `https://api.github.com/repos/${spec.owner}/${spec.repo}/commits/${encodeURIComponent(spec.ref)}`;
114
+ const res = await ghFetch(url);
115
+ if (res.status === 404) {
116
+ fail(`${spec.slug}@${spec.ref} not found or not accessible (set GITHUB_TOKEN for private repos)`);
117
+ }
118
+ if (!res.ok) {
119
+ fail(`GitHub API error ${res.status} resolving ${spec.slug}@${spec.ref}`);
120
+ }
121
+ const data = await res.json();
122
+ if (!data || !data.sha) fail(`${spec.slug}@${spec.ref} did not resolve to a commit`);
123
+ return String(data.sha).toLowerCase();
124
+ }
125
+
126
+ async function fetchFile(spec, sha, filePath, { required = false } = {}) {
127
+ const url = `https://api.github.com/repos/${spec.owner}/${spec.repo}/contents/${filePath}?ref=${sha}`;
128
+ const res = await ghFetch(url, 'application/vnd.github.raw');
129
+ if (res.status === 404) {
130
+ if (required) fail(`${spec.slug}@${sha.slice(0, 7)} has no ${filePath}`);
131
+ return null;
132
+ }
133
+ if (!res.ok) {
134
+ fail(`GitHub API error ${res.status} fetching ${filePath} from ${spec.slug}@${sha.slice(0, 7)}`);
135
+ }
136
+ return await res.text();
137
+ }
138
+
139
+ function parseJson(text, label) {
140
+ try {
141
+ return JSON.parse(text);
142
+ } catch (err) {
143
+ fail(`Invalid JSON in ${label}: ${err.message}`);
144
+ }
145
+ }
146
+
147
+ function parsePyProjectName(text) {
148
+ const project = sectionText(text, 'project');
149
+ const m = project && project.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
150
+ return m ? m[1] : null;
151
+ }
152
+
153
+ function sectionText(text, name) {
154
+ const re = new RegExp(`^\\s*\\[${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\s*$`, 'm');
155
+ const m = re.exec(text);
156
+ if (!m) return null;
157
+ const start = m.index + m[0].length;
158
+ const rest = text.slice(start);
159
+ const next = /^\s*\[[^\]]+\]\s*$/m.exec(rest);
160
+ return next ? rest.slice(0, next.index) : rest;
161
+ }
162
+
163
+ async function detectTarget(spec, sha) {
164
+ const [depsEdn, packageJsonText, pyprojectText] = await Promise.all([
165
+ fetchFile(spec, sha, 'deps.edn'),
166
+ fetchFile(spec, sha, 'package.json'),
167
+ fetchFile(spec, sha, 'pyproject.toml'),
168
+ ]);
169
+
170
+ const found = [];
171
+ if (depsEdn != null) found.push('clojure');
172
+ if (packageJsonText != null) found.push('typescript');
173
+ if (pyprojectText != null) found.push('python');
174
+ if (found.length === 0) {
175
+ fail(`${spec.slug}@${sha.slice(0, 7)} has no deps.edn, package.json, or pyproject.toml`);
176
+ }
177
+ if (found.length > 1) {
178
+ fail(`${spec.slug}@${sha.slice(0, 7)} is ambiguous; found ${found.join(', ')} manifests`);
179
+ }
180
+
181
+ if (found[0] === 'typescript') {
182
+ const pkg = parseJson(packageJsonText, 'package.json');
183
+ if (!pkg.name) fail(`${spec.slug}@${sha.slice(0, 7)} package.json has no name`);
184
+ return { language: 'typescript', packageName: pkg.name };
185
+ }
186
+ if (found[0] === 'python') {
187
+ const packageName = parsePyProjectName(pyprojectText);
188
+ if (!packageName) fail(`${spec.slug}@${sha.slice(0, 7)} pyproject.toml has no [project].name`);
189
+ return { language: 'python', packageName };
190
+ }
191
+ return { language: 'clojure', packageName: `io.github.${spec.owner}/${spec.repo}` };
192
+ }
193
+
194
+ // --- native metadata -----------------------------------------------------
195
+
196
+ function metadataFromPackageJson(file) {
197
+ if (!fs.existsSync(file)) return null;
198
+ const pkg = parseJson(fs.readFileSync(file, 'utf8'), file);
199
+ if (!pkg.bigconfig) return null;
200
+ return { ...pkg.bigconfig, language: pkg.bigconfig.language || 'typescript', manifest: file };
201
+ }
202
+
203
+ function metadataFromPyproject(file) {
204
+ if (!fs.existsSync(file)) return null;
205
+ const text = fs.readFileSync(file, 'utf8');
206
+ const sec = sectionText(text, 'tool.bigconfig');
207
+ if (!sec) return null;
208
+ const get = (key) => {
209
+ const m = sec.match(new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*["']([^"']+)["']`, 'm'));
210
+ return m ? m[1] : undefined;
211
+ };
212
+ return {
213
+ repo: get('repo'),
214
+ ref: get('ref'),
215
+ sha: get('sha'),
216
+ language: get('language') || 'python',
217
+ run: get('run') || 'run',
218
+ packageName: get('package-name'),
219
+ manifest: file,
220
+ };
221
+ }
222
+
223
+ function metadataFromDepsEdn(file) {
224
+ if (!fs.existsSync(file)) return null;
225
+ const text = fs.readFileSync(file, 'utf8');
226
+ if (!text.includes(':bigconfig/repo')) return null;
227
+ const get = (key) => {
228
+ const m = text.match(new RegExp(`:${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+"([^"]+)"`));
229
+ return m ? m[1] : undefined;
230
+ };
231
+ return {
232
+ repo: get('bigconfig/repo'),
233
+ ref: get('bigconfig/ref'),
234
+ sha: get('bigconfig/sha'),
235
+ language: get('bigconfig/language') || 'clojure',
236
+ run: get('bigconfig/run') || 'run',
237
+ manifest: file,
238
+ };
239
+ }
240
+
241
+ function readMetadata(cwd = process.cwd()) {
242
+ const metas = [
243
+ metadataFromDepsEdn(path.join(cwd, 'deps.edn')),
244
+ metadataFromPackageJson(path.join(cwd, 'package.json')),
245
+ metadataFromPyproject(path.join(cwd, 'pyproject.toml')),
246
+ ].filter(Boolean);
247
+ if (metas.length > 1) {
248
+ fail('Multiple BigConfig metadata files found; keep only one of deps.edn, package.json, or pyproject.toml initialized for bc-pkg.');
249
+ }
250
+ if (metas.length === 0) return null;
251
+ const meta = metas[0];
252
+ if (!meta.repo || !meta.ref || !meta.sha || !meta.language) {
253
+ fail(`Incomplete BigConfig metadata in ${meta.manifest}`);
254
+ }
255
+ return meta;
256
+ }
257
+
258
+ function validateExistingMetadata(meta, spec, sha) {
259
+ const expectedRepo = spec.slug;
260
+ const problems = [];
261
+ if (meta.repo !== expectedRepo) problems.push(`repo ${JSON.stringify(meta.repo)} != ${JSON.stringify(expectedRepo)}`);
262
+ if (meta.ref !== spec.ref) problems.push(`ref ${JSON.stringify(meta.ref)} != ${JSON.stringify(spec.ref)}`);
263
+ if (String(meta.sha).toLowerCase() !== sha.toLowerCase()) problems.push(`sha ${meta.sha} != ${sha}`);
264
+ if (problems.length) {
265
+ fail(`Current directory is already initialized for a different BigConfig package:\n ${problems.join('\n ')}`);
266
+ }
267
+ }
268
+
269
+ function quoteToml(s) {
270
+ return JSON.stringify(String(s));
271
+ }
272
+
273
+ function writeRunFile(text) {
274
+ const target = path.join(process.cwd(), 'run');
275
+ fs.writeFileSync(target, text);
276
+ if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
277
+ }
278
+
279
+ function clojureCoord(spec) {
280
+ return `io.github.${spec.owner}/${spec.repo}`;
281
+ }
282
+
283
+ function writeClojureManifest(spec, sha, target) {
284
+ const coord = clojureCoord(spec);
285
+ const gitUrl = `https://github.com/${spec.owner}/${spec.repo}.git`;
286
+ const deps = `{:deps {${coord} {:git/url "${gitUrl}"\n :git/sha "${sha}"}}\n :bigconfig/repo "${spec.slug}"\n :bigconfig/ref "${spec.ref}"\n :bigconfig/sha "${sha}"\n :bigconfig/language "clojure"\n :bigconfig/run "run"}\n`;
287
+ fs.writeFileSync(path.join(process.cwd(), 'deps.edn'), deps);
288
+
289
+ // Babashka script execution reads bb.edn, not deps.edn. Metadata remains in
290
+ // deps.edn per the launcher contract; bb.edn is the runtime dependency file.
291
+ const bb = `{:deps {${coord} {:git/url "${gitUrl}"\n :git/sha "${sha}"}}}\n`;
292
+ fs.writeFileSync(path.join(process.cwd(), 'bb.edn'), bb);
293
+ }
294
+
295
+ function writeTypeScriptManifest(spec, sha, target) {
296
+ const file = path.join(process.cwd(), 'package.json');
297
+ let pkg = {};
298
+ if (fs.existsSync(file)) {
299
+ pkg = parseJson(fs.readFileSync(file, 'utf8'), file);
300
+ if (pkg.bigconfig) validateExistingMetadata(pkg.bigconfig, spec, sha);
301
+ }
302
+ pkg.type = pkg.type || 'module';
303
+ pkg.scripts = { ...(pkg.scripts || {}), run: 'node run' };
304
+ pkg.dependencies = { ...(pkg.dependencies || {}) };
305
+ pkg.dependencies[target.packageName] = `github:${spec.owner}/${spec.repo}#${sha}`;
306
+ pkg.bigconfig = {
307
+ repo: spec.slug,
308
+ ref: spec.ref,
309
+ sha,
310
+ language: 'typescript',
311
+ run: 'run',
312
+ packageName: target.packageName,
313
+ };
314
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
315
+ }
316
+
317
+ function writePythonManifest(spec, sha, target) {
318
+ const file = path.join(process.cwd(), 'pyproject.toml');
319
+ if (fs.existsSync(file)) {
320
+ const existing = metadataFromPyproject(file);
321
+ if (!existing) {
322
+ fail('pyproject.toml already exists and is not initialized for bc-pkg; refusing to rewrite it.');
323
+ }
324
+ validateExistingMetadata(existing, spec, sha);
325
+ }
326
+ const dep = `${target.packageName} @ git+https://github.com/${spec.owner}/${spec.repo}.git@${sha}`;
327
+ const text = `[project]\nname = "bigconfig-cli"\nversion = "0.1.0"\nrequires-python = ">=3.12"\ndependencies = [\n ${quoteToml(dep)},\n]\n\n[tool.bigconfig]\nrepo = ${quoteToml(spec.slug)}\nref = ${quoteToml(spec.ref)}\nsha = ${quoteToml(sha)}\nlanguage = "python"\nrun = "run"\npackage-name = ${quoteToml(target.packageName)}\n`;
328
+ fs.writeFileSync(file, text);
329
+ }
330
+
331
+ function writeNativeManifest(spec, sha, target) {
332
+ if (target.language === 'clojure') return writeClojureManifest(spec, sha, target);
333
+ if (target.language === 'typescript') return writeTypeScriptManifest(spec, sha, target);
334
+ if (target.language === 'python') return writePythonManifest(spec, sha, target);
335
+ fail(`Unsupported language: ${target.language}`);
336
+ }
337
+
338
+ // --- target dependency setup and execution -------------------------------
339
+
340
+ async function ensureTargetDeps(meta) {
341
+ if (meta.language === 'typescript') {
342
+ requireCommand('node', 'Install Node.js and try again.');
343
+ requireCommand('npm', 'Install npm and try again.');
344
+ if (!fs.existsSync(path.join(process.cwd(), 'node_modules'))) {
345
+ log('Installing TypeScript target dependencies with npm install...');
346
+ const code = await runCommand('npm', ['install']);
347
+ if (code !== 0) process.exit(code);
348
+ }
349
+ return;
350
+ }
351
+ if (meta.language === 'python') {
352
+ const py = whichPython();
353
+ if (!py) fail('python3 or python is required but was not found on PATH.');
354
+ requireCommand('uv', 'Install uv and try again.');
355
+ if (!fs.existsSync(path.join(process.cwd(), '.venv'))) {
356
+ log('Installing Python target dependencies with uv sync...');
357
+ const code = await runCommand('uv', ['sync']);
358
+ if (code !== 0) process.exit(code);
359
+ }
360
+ return;
361
+ }
362
+ }
363
+
364
+ async function runTarget(meta, args) {
365
+ if (meta.language === 'typescript') {
366
+ await ensureTargetDeps(meta);
367
+ return await runCommand('node', ['run', ...args]);
368
+ }
369
+ if (meta.language === 'python') {
370
+ await ensureTargetDeps(meta);
371
+ return await runCommand('uv', ['run', 'python', meta.run || 'run', ...args]);
372
+ }
373
+ if (meta.language === 'clojure') {
374
+ const p = resolvePlatform();
375
+ const bbPath = await ensureBabashka(p);
376
+ const javaHome = await ensureJdk(p);
377
+ ensureGit();
378
+ return await runBb(bbPath, ['run', ...args], javaHome);
379
+ }
380
+ fail(`Unsupported language: ${meta.language}`);
381
+ }
382
+
383
+ async function initialize(spec, sha) {
384
+ const target = await detectTarget(spec, sha);
385
+ const runText = await fetchFile(spec, sha, 'run', { required: true });
386
+ writeRunFile(runText);
387
+ writeNativeManifest(spec, sha, target);
388
+ return {
389
+ repo: spec.slug,
390
+ ref: spec.ref,
391
+ sha,
392
+ language: target.language,
393
+ run: 'run',
394
+ packageName: target.packageName,
395
+ };
396
+ }
397
+
398
+ async function restoreRunIfMissing(meta) {
399
+ const runPath = path.join(process.cwd(), meta.run || 'run');
400
+ if (fs.existsSync(runPath)) return;
401
+ const [owner, repo] = meta.repo.split('/');
402
+ const spec = { owner, repo, slug: meta.repo, ref: meta.ref };
403
+ const runText = await fetchFile(spec, meta.sha, 'run', { required: true });
404
+ writeRunFile(runText);
405
+ }
406
+
407
+ // --- Babashka/JDK bootstrap for Clojure targets --------------------------
27
408
 
28
- // Maps the host OS/arch to babashka release asset + Adoptium API parameters.
29
409
  function resolvePlatform() {
30
- const plat = process.platform; // 'darwin' | 'linux' | 'win32'
31
- const arch = process.arch; // 'arm64' | 'x64'
410
+ const plat = process.platform;
411
+ const arch = process.arch;
32
412
  const exeSuffix = plat === 'win32' ? '.exe' : '';
33
413
 
34
414
  let bbOs;
@@ -47,7 +427,7 @@ function resolvePlatform() {
47
427
  jdkOs = 'windows';
48
428
  archiveExt = 'zip';
49
429
  } else {
50
- throw new Error(`Unsupported OS: ${plat}`);
430
+ fail(`Unsupported OS: ${plat}`);
51
431
  }
52
432
 
53
433
  let bbArch;
@@ -59,17 +439,11 @@ function resolvePlatform() {
59
439
  bbArch = 'amd64';
60
440
  jdkArch = 'x64';
61
441
  } else {
62
- throw new Error(`Unsupported CPU architecture: ${arch}`);
442
+ fail(`Unsupported CPU architecture: ${arch}`);
63
443
  }
64
444
 
65
- // babashka ships only a *static* (musl) build for Linux arm64 there is no
66
- // dynamic linux-aarch64 asset. The static build also runs on glibc.
67
- const bbArchToken =
68
- plat === 'linux' && arch === 'arm64' ? 'aarch64-static' : bbArch;
69
-
70
- if (plat === 'win32' && arch === 'arm64') {
71
- throw new Error('babashka has no prebuilt Windows arm64 binary');
72
- }
445
+ const bbArchToken = plat === 'linux' && arch === 'arm64' ? 'aarch64-static' : bbArch;
446
+ if (plat === 'win32' && arch === 'arm64') fail('babashka has no prebuilt Windows arm64 binary');
73
447
 
74
448
  return {
75
449
  exeSuffix,
@@ -84,22 +458,16 @@ function resolvePlatform() {
84
458
  }
85
459
 
86
460
  function cacheRoot() {
87
- const base =
88
- process.platform === 'win32'
89
- ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
90
- : process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
461
+ const base = process.platform === 'win32'
462
+ ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
463
+ : process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
91
464
  return path.join(base, 'bc-pkg');
92
465
  }
93
466
 
94
- // --- Filesystem helpers --------------------------------------------------
95
-
96
467
  function rmrf(target) {
97
468
  fs.rmSync(target, { recursive: true, force: true });
98
469
  }
99
470
 
100
- // Runs `install(tmpDir)` into a private temp dir, then atomically renames it
101
- // into place. Concurrent first-runs race on the rename; the loser is discarded
102
- // so the final dir is never left half-written.
103
471
  async function installOnce(finalDir, install) {
104
472
  if (fs.existsSync(finalDir)) return;
105
473
  const tmp = `${finalDir}.tmp-${process.pid}-${Date.now()}`;
@@ -107,7 +475,7 @@ async function installOnce(finalDir, install) {
107
475
  try {
108
476
  await install(tmp);
109
477
  if (fs.existsSync(finalDir)) {
110
- rmrf(tmp); // another process won the race
478
+ rmrf(tmp);
111
479
  return;
112
480
  }
113
481
  fs.mkdirSync(path.dirname(finalDir), { recursive: true });
@@ -127,52 +495,28 @@ async function installOnce(finalDir, install) {
127
495
  }
128
496
 
129
497
  async function download(url, destFile) {
130
- const res = await fetch(url, {
131
- redirect: 'follow',
132
- headers: { 'user-agent': 'bc-pkg' },
133
- });
498
+ const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'bc-pkg' } });
134
499
  if (!res.ok || !res.body) {
135
- throw new Error(
136
- `Download failed (HTTP ${res.status} ${res.statusText})\n ${url}`
137
- );
500
+ fail(`Download failed (HTTP ${res.status} ${res.statusText})\n ${url}`);
138
501
  }
139
502
  await fs.promises.mkdir(path.dirname(destFile), { recursive: true });
140
503
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destFile));
141
504
  }
142
505
 
143
- // Extracts .tar.gz / .zip. System `tar` (GNU on Linux, bsdtar on macOS &
144
- // Windows 10+) auto-detects gzip and handles zip; PowerShell is a Windows
145
- // fallback when `tar` is absent.
146
506
  function extract(archive, destDir) {
147
507
  fs.mkdirSync(destDir, { recursive: true });
148
- let r = spawnSync('tar', ['-xf', archive, '-C', destDir], {
149
- stdio: ['ignore', 'inherit', 'inherit'],
150
- });
508
+ let r = spawnSync('tar', ['-xf', archive, '-C', destDir], { stdio: ['ignore', 'inherit', 'inherit'] });
151
509
  if (r.error && r.error.code === 'ENOENT') {
152
510
  if (process.platform === 'win32' && archive.endsWith('.zip')) {
153
- r = spawnSync(
154
- 'powershell',
155
- [
156
- '-NoProfile',
157
- '-Command',
158
- `Expand-Archive -LiteralPath '${archive}' -DestinationPath '${destDir}' -Force`,
159
- ],
160
- { stdio: ['ignore', 'inherit', 'inherit'] }
161
- );
162
- if (r.status !== 0) {
163
- throw new Error(`Failed to extract ${archive} (PowerShell fallback)`);
164
- }
511
+ r = spawnSync('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archive}' -DestinationPath '${destDir}' -Force`], { stdio: ['ignore', 'inherit', 'inherit'] });
512
+ if (r.status !== 0) fail(`Failed to extract ${archive} (PowerShell fallback)`);
165
513
  return;
166
514
  }
167
- throw new Error(`'tar' not found on PATH; cannot extract ${archive}`);
168
- }
169
- if (r.status !== 0) {
170
- throw new Error(`Failed to extract ${archive} (tar exit ${r.status})`);
515
+ fail(`'tar' not found on PATH; cannot extract ${archive}`);
171
516
  }
517
+ if (r.status !== 0) fail(`Failed to extract ${archive} (tar exit ${r.status})`);
172
518
  }
173
519
 
174
- // Locates JAVA_HOME inside an extracted JDK. Layout differs per OS
175
- // (linux: <root>/bin/java, macOS: <root>/Contents/Home/bin/java).
176
520
  function findJavaHome(root, exeSuffix) {
177
521
  const javaRel = path.join('bin', `java${exeSuffix}`);
178
522
  const stack = [root];
@@ -185,15 +529,11 @@ function findJavaHome(root, exeSuffix) {
185
529
  } catch {
186
530
  continue;
187
531
  }
188
- for (const e of entries) {
189
- if (e.isDirectory()) stack.push(path.join(dir, e.name));
190
- }
532
+ for (const e of entries) if (e.isDirectory()) stack.push(path.join(dir, e.name));
191
533
  }
192
534
  return null;
193
535
  }
194
536
 
195
- // --- Bootstrap steps -----------------------------------------------------
196
-
197
537
  async function ensureBabashka(p) {
198
538
  const version = DEFAULT_BB_VERSION;
199
539
  const finalDir = path.join(cacheRoot(), 'bb', version);
@@ -212,23 +552,13 @@ async function ensureBabashka(p) {
212
552
  extract(archive, tmp);
213
553
  fs.unlinkSync(archive);
214
554
  const exe = path.join(tmp, `bb${p.exeSuffix}`);
215
- if (!fs.existsSync(exe)) {
216
- throw new Error('babashka binary not found after extraction');
217
- }
555
+ if (!fs.existsSync(exe)) fail('babashka binary not found after extraction');
218
556
  if (process.platform !== 'win32') fs.chmodSync(exe, 0o755);
219
557
  });
220
558
 
221
- if (!fs.existsSync(bbPath)) {
222
- throw new Error(
223
- `babashka cache looks corrupt; remove ${finalDir} and retry`
224
- );
225
- }
559
+ if (!fs.existsSync(bbPath)) fail(`babashka cache looks corrupt; remove ${finalDir} and retry`);
226
560
  if (process.platform !== 'win32') {
227
- try {
228
- fs.chmodSync(bbPath, 0o755);
229
- } catch {
230
- /* already executable */
231
- }
561
+ try { fs.chmodSync(bbPath, 0o755); } catch {}
232
562
  }
233
563
  return bbPath;
234
564
  }
@@ -240,9 +570,7 @@ async function ensureJdk(p) {
240
570
 
241
571
  await installOnce(finalDir, async (tmp) => {
242
572
  const archive = path.join(tmp, p.jdkArchiveName);
243
- const url =
244
- `https://api.adoptium.net/v3/binary/latest/${feature}/ga/` +
245
- `${p.jdkOs}/${p.jdkArch}/jdk/hotspot/normal/eclipse`;
573
+ const url = `https://api.adoptium.net/v3/binary/latest/${feature}/ga/${p.jdkOs}/${p.jdkArch}/jdk/hotspot/normal/eclipse`;
246
574
  log(`Installing Temurin JDK ${feature} (set JDK_VERSION to override)...`);
247
575
  try {
248
576
  await download(url, archive);
@@ -252,8 +580,7 @@ async function ensureJdk(p) {
252
580
  extract(archive, tmp);
253
581
  fs.unlinkSync(archive);
254
582
  const home = findJavaHome(tmp, p.exeSuffix);
255
- if (!home) throw new Error('could not locate java in extracted JDK');
256
- // Path relative to tmp stays valid after tmp is renamed to finalDir.
583
+ if (!home) fail('could not locate java in extracted JDK');
257
584
  fs.writeFileSync(path.join(tmp, '.javahome'), path.relative(tmp, home));
258
585
  });
259
586
 
@@ -263,75 +590,33 @@ async function ensureJdk(p) {
263
590
  } catch {
264
591
  javaHome = null;
265
592
  }
266
- if (
267
- !javaHome ||
268
- !fs.existsSync(path.join(javaHome, 'bin', `java${p.exeSuffix}`))
269
- ) {
593
+ if (!javaHome || !fs.existsSync(path.join(javaHome, 'bin', `java${p.exeSuffix}`))) {
270
594
  javaHome = findJavaHome(finalDir, p.exeSuffix);
271
595
  }
272
- if (!javaHome) {
273
- throw new Error(`JDK cache looks corrupt; remove ${finalDir} and retry`);
274
- }
596
+ if (!javaHome) fail(`JDK cache looks corrupt; remove ${finalDir} and retry`);
275
597
  return javaHome;
276
598
  }
277
599
 
278
- // --- git (Linux only) ----------------------------------------------------
279
-
280
- function commandWorks(cmd, args) {
281
- const r = spawnSync(cmd, args, { stdio: 'ignore' });
282
- return !r.error && r.status === 0;
283
- }
284
-
285
- // ENOENT sets r.error; any exit code otherwise means the binary exists.
286
- function binExists(cmd) {
287
- return !spawnSync(cmd, ['--version'], { stdio: 'ignore' }).error;
288
- }
289
-
290
- // On Linux, install git via the system package manager if it is missing.
291
- // Skipped when git is already on PATH; a no-op on macOS/Windows.
292
600
  function ensureGit() {
293
601
  if (process.platform !== 'linux') return;
294
602
  if (commandWorks('git', ['--version'])) return;
295
603
 
296
- const isRoot =
297
- typeof process.getuid === 'function' && process.getuid() === 0;
604
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
298
605
  const sudo = isRoot ? [] : binExists('sudo') ? ['sudo'] : null;
299
606
  if (sudo === null) {
300
- throw new Error(
301
- 'git is missing and cannot be installed: not running as root and ' +
302
- '`sudo` is unavailable.\n' +
303
- ' Install git manually (e.g. `apt-get install git`) and re-run.'
304
- );
607
+ fail('git is missing and cannot be installed: not running as root and `sudo` is unavailable.\n Install git manually and re-run.');
305
608
  }
306
609
 
307
- // First match wins; `soft` lists step indexes allowed to fail (e.g.
308
- // `apt-get update`, which is non-fatal if package lists already exist).
309
610
  const managers = [
310
- {
311
- bin: 'apt-get',
312
- steps: [
313
- ['apt-get', 'update', '-y'],
314
- ['apt-get', 'install', '-y', 'git'],
315
- ],
316
- soft: [0],
317
- },
611
+ { bin: 'apt-get', steps: [['apt-get', 'update', '-y'], ['apt-get', 'install', '-y', 'git']], soft: [0] },
318
612
  { bin: 'dnf', steps: [['dnf', 'install', '-y', 'git']] },
319
613
  { bin: 'yum', steps: [['yum', 'install', '-y', 'git']] },
320
- {
321
- bin: 'zypper',
322
- steps: [['zypper', '--non-interactive', 'install', 'git']],
323
- },
614
+ { bin: 'zypper', steps: [['zypper', '--non-interactive', 'install', 'git']] },
324
615
  { bin: 'pacman', steps: [['pacman', '-S', '--noconfirm', 'git']] },
325
616
  { bin: 'apk', steps: [['apk', 'add', '--no-cache', 'git']] },
326
617
  ];
327
618
  const pm = managers.find((m) => binExists(m.bin));
328
- if (!pm) {
329
- throw new Error(
330
- 'git is missing and no supported package manager ' +
331
- '(apt-get/dnf/yum/zypper/pacman/apk) was found.\n' +
332
- ' Install git manually and re-run.'
333
- );
334
- }
619
+ if (!pm) fail('git is missing and no supported package manager was found. Install git manually and re-run.');
335
620
 
336
621
  log(`Installing git via ${pm.bin}${sudo.length ? ' (sudo)' : ''}...`);
337
622
  const env = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
@@ -341,18 +626,12 @@ function ensureGit() {
341
626
  const ok = !r.error && r.status === 0;
342
627
  if (!ok && !(pm.soft && pm.soft.includes(i))) {
343
628
  const why = r.error ? r.error.code : `exit ${r.status}`;
344
- throw new Error(`git install failed: \`${argv.join(' ')}\` (${why}).`);
629
+ fail(`git install failed: \`${argv.join(' ')}\` (${why}).`);
345
630
  }
346
631
  });
347
-
348
- if (!commandWorks('git', ['--version'])) {
349
- throw new Error('git still not available after the install attempt.');
350
- }
632
+ if (!commandWorks('git', ['--version'])) fail('git still not available after the install attempt.');
351
633
  }
352
634
 
353
- // --- bb.edn bootstrap ----------------------------------------------------
354
-
355
- // Env augmented so spawned processes find the cached JDK and bb; nothing system-wide.
356
635
  function bbEnv(javaHome, bbPath, extraEnv = {}) {
357
636
  const env = { ...process.env, ...extraEnv };
358
637
  const pathEntries = [];
@@ -360,293 +639,36 @@ function bbEnv(javaHome, bbPath, extraEnv = {}) {
360
639
  env.JAVA_HOME = javaHome;
361
640
  pathEntries.push(path.join(javaHome, 'bin'));
362
641
  }
363
- if (bbPath) {
364
- pathEntries.push(path.dirname(bbPath));
365
- }
366
- if (pathEntries.length) {
367
- env.PATH = pathEntries.join(path.delimiter) + path.delimiter + (env.PATH || '');
368
- }
642
+ if (bbPath) pathEntries.push(path.dirname(bbPath));
643
+ if (pathEntries.length) env.PATH = pathEntries.join(path.delimiter) + path.delimiter + (env.PATH || '');
369
644
  return env;
370
645
  }
371
646
 
372
- function ghFetch(url, accept) {
373
- const headers = {
374
- 'user-agent': 'bc-pkg',
375
- accept: accept || 'application/vnd.github+json',
376
- 'x-github-api-version': '2022-11-28',
377
- };
378
- if (process.env.GITHUB_TOKEN) {
379
- headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
380
- }
381
- return fetch(url, { headers, redirect: 'follow' });
382
- }
383
-
384
- // Reads/edits the EDN with borkdude/rewrite-edn so comments & formatting of
385
- // untouched nodes survive. Params are passed via env to avoid quoting issues.
386
- const REWRITE_SCRIPT = `(require '[babashka.deps :as deps])
387
- (deps/add-deps {:deps {'borkdude/rewrite-edn {:mvn/version (System/getenv "BBEDN_REWRITE_VERSION")}}})
388
- (require '[borkdude.rewrite-edn :as r])
389
-
390
- (defn fail! [msg]
391
- (binding [*out* *err*]
392
- (println msg))
393
- (System/exit 1))
394
-
395
- (defn local-root? [coord]
396
- (and (map? coord) (contains? coord :local/root)))
397
-
398
- ;; Drop every entry whose effective coord (sexpr respects #_ discard) is a
399
- ;; map containing :local/root. Returns the (possibly unchanged) map node.
400
- (defn strip-local-root [m-node]
401
- (if (nil? m-node)
402
- m-node
403
- (let [m (r/sexpr m-node)]
404
- (reduce (fn [acc k]
405
- (if (local-root? (get m k)) (r/dissoc acc k) acc))
406
- m-node
407
- (keys m)))))
408
-
409
- (let [in (System/getenv "BBEDN_IN")
410
- out (System/getenv "BBEDN_OUT")
411
- owner (System/getenv "BBEDN_OWNER")
412
- proj (System/getenv "BBEDN_PROJECT")
413
- sha (System/getenv "BBEDN_SHA")
414
- repo (System/getenv "BBEDN_REPO")
415
- dep (symbol (str "io.github." owner) proj)
416
- nodes (r/parse-string (slurp in))
417
- data (r/sexpr nodes)
418
- existing-repo (if (and (map? data) (contains? data :repo))
419
- (:repo data)
420
- ::missing)
421
- _validate-map (when-not (map? data)
422
- (fail! "Downloaded bb.edn must contain a top-level EDN map"))
423
- _validate-repo (when (and (not= ::missing existing-repo)
424
- (not= existing-repo repo))
425
- (fail! (str "Downloaded bb.edn :repo " (pr-str existing-repo)
426
- " does not match CLI repo " (pr-str repo))))
427
- nodes (if (= ::missing existing-repo) (r/assoc nodes :repo repo) nodes)
428
- ;; 1. strip :local/root from top-level :deps
429
- nodes (if (r/get nodes :deps)
430
- (r/update nodes :deps strip-local-root)
431
- nodes)
432
- ;; 2. strip :local/root from each task's :extra-deps
433
- tasks (some-> (r/get nodes :tasks) r/sexpr)
434
- nodes (reduce (fn [acc tk]
435
- (let [tv (get tasks tk)]
436
- (if (and (map? tv) (map? (:extra-deps tv)))
437
- (r/update-in acc [:tasks tk :extra-deps] strip-local-root)
438
- acc)))
439
- nodes
440
- (keys tasks))
441
- ;; 3. ensure :deps exists, then inject the repo as a git dep
442
- nodes (if (nil? (r/get nodes :deps)) (r/assoc nodes :deps {}) nodes)
443
- nodes (r/assoc-in nodes [:deps dep] {:git/sha sha})]
444
- (spit out (str nodes)))
445
- `;
446
-
447
- const VALIDATE_REPO_SCRIPT = `(require '[clojure.edn :as edn])
448
-
449
- (defn fail! [msg]
450
- (binding [*out* *err*]
451
- (println msg))
452
- (System/exit 1))
453
-
454
- (let [target (System/getenv "BBEDN_TARGET")
455
- cli-repo (System/getenv "BBEDN_REPO")
456
- data (try
457
- (edn/read-string (slurp target))
458
- (catch Exception e
459
- (fail! (str "Invalid bb.edn: " (.getMessage e)))))
460
- has-repo? (and (map? data) (contains? data :repo))
461
- file-repo (when has-repo? (:repo data))]
462
- (when-not (map? data)
463
- (fail! "Invalid bb.edn: expected top-level EDN map"))
464
- (when-not has-repo?
465
- (fail! (str "bb.edn exists but does not contain :repo; omit the CLI repo or add :repo "
466
- (pr-str cli-repo))))
467
- (when-not (= file-repo cli-repo)
468
- (fail! (str "bb.edn :repo " (pr-str file-repo)
469
- " does not match CLI repo " (pr-str cli-repo)))))
470
- `;
471
-
472
- // Regex for the "owner/project" slug shape. Anchored, no slashes/spaces/@
473
- // inside either segment. When present as the first argument, it is consumed as
474
- // repo identity and never forwarded to bb.
475
- const REPO_SLUG_RE = /^([^/\s@]+)\/([^/\s@]+)$/;
476
-
477
- function validateBbEdnRepo(bbPath, javaHome, target, repo) {
478
- const targetPath = path.resolve(target);
479
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bb-edn-validate-'));
480
- try {
481
- const script = path.join(tmp, 'validate-repo.clj');
482
- fs.writeFileSync(script, VALIDATE_REPO_SCRIPT);
483
- const r = spawnSync(bbPath, [script], {
484
- stdio: ['ignore', 'pipe', 'pipe'],
485
- encoding: 'utf8',
486
- cwd: tmp,
487
- env: bbEnv(javaHome, bbPath, {
488
- BBEDN_TARGET: targetPath,
489
- BBEDN_REPO: repo,
490
- }),
491
- });
492
- if (r.error || r.status !== 0) {
493
- const details = [r.stderr, r.stdout]
494
- .filter(Boolean)
495
- .map((s) => s.trim())
496
- .filter(Boolean)
497
- .join('\n');
498
- const why = r.error ? r.error.message : `exit ${r.status}`;
499
- throw new Error(details || `bb.edn repo validation failed (${why})`);
500
- }
501
- } finally {
502
- rmrf(tmp);
503
- }
647
+ function runBb(bbPath, args, javaHome, extraEnv = {}) {
648
+ return runCommand(bbPath, args, { env: bbEnv(javaHome, bbPath, extraEnv) });
504
649
  }
505
650
 
506
- // If bb.edn exists and a CLI repo was provided, validate its top-level :repo.
507
- // Otherwise, when cwd has no bb.edn and `repo` is "owner/project", fetch that
508
- // repo's bb.edn (pinned to its default-branch HEAD), inject top-level :repo,
509
- // and add the repo itself as an io.github git dep.
510
- async function ensureBbEdn(bbPath, javaHome, repo) {
511
- let owner = null;
512
- let project = null;
513
- let slug = null;
514
- if (repo) {
515
- const m = repo.match(REPO_SLUG_RE);
516
- if (!m) {
517
- throw new Error(`repo must be "owner/project" (got "${repo}")`);
518
- }
519
- owner = m[1];
520
- project = m[2];
521
- slug = `${owner}/${project}`;
522
- }
523
-
524
- const target = path.join(process.cwd(), 'bb.edn');
525
- if (fs.existsSync(target)) {
526
- if (repo) validateBbEdnRepo(bbPath, javaHome, target, slug);
527
- return null;
528
- }
529
- if (!repo) return null; // step disabled — proceed straight to bb
530
-
531
- const api = `https://api.github.com/repos/${owner}/${project}`;
532
-
533
- const cr = await ghFetch(`${api}/commits?per_page=1`);
534
- if (cr.status === 404) {
535
- throw new Error(
536
- `${slug} not found or not accessible ` +
537
- `(set GITHUB_TOKEN for private repos)`
538
- );
539
- }
540
- if (!cr.ok) {
541
- throw new Error(`GitHub API error ${cr.status} resolving ${slug}`);
542
- }
543
- const commits = await cr.json();
544
- const sha = Array.isArray(commits) && commits[0] && commits[0].sha;
545
- if (!sha) throw new Error(`${slug} has no commits`);
651
+ // --- main ----------------------------------------------------------------
546
652
 
547
- const fr = await ghFetch(
548
- `${api}/contents/bb.edn?ref=${sha}`,
549
- 'application/vnd.github.raw'
550
- );
551
- if (fr.status === 404) {
552
- throw new Error(`${slug} (at ${sha.slice(0, 7)}) has no bb.edn`);
553
- }
554
- if (!fr.ok) {
555
- throw new Error(`GitHub API error ${fr.status} fetching bb.edn from ${slug}`);
556
- }
557
- const ednText = await fr.text();
653
+ async function main(argv) {
654
+ let args = [...argv];
655
+ let spec = args.length ? parseSpec(args[0]) : null;
656
+ if (spec) args = args.slice(1);
558
657
 
559
- log(`Bootstrapping bb.edn from ${slug}@${sha.slice(0, 7)}...`);
560
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bb-edn-'));
561
- try {
562
- const inFile = path.join(tmp, 'in.edn');
563
- const script = path.join(tmp, 'rewrite.clj');
564
- fs.writeFileSync(inFile, ednText);
565
- fs.writeFileSync(script, REWRITE_SCRIPT);
566
- const env = bbEnv(javaHome, bbPath);
567
- Object.assign(env, {
568
- BBEDN_IN: inFile,
569
- BBEDN_OUT: target,
570
- BBEDN_OWNER: owner,
571
- BBEDN_PROJECT: project,
572
- BBEDN_REPO: slug,
573
- BBEDN_SHA: sha,
574
- BBEDN_REWRITE_VERSION: REWRITE_EDN_VERSION,
575
- });
576
- const r = spawnSync(bbPath, [script], {
577
- stdio: ['ignore', 'pipe', 'pipe'],
578
- encoding: 'utf8',
579
- cwd: tmp,
580
- env,
581
- });
582
- if (r.error || r.status !== 0) {
583
- const details = [r.stderr, r.stdout]
584
- .filter(Boolean)
585
- .map((s) => s.trim())
586
- .filter(Boolean)
587
- .join('\n');
588
- const why = r.error ? r.error.message : `exit ${r.status}`;
589
- throw new Error(
590
- details || `failed to write bb.edn via rewrite-edn (${why})`
591
- );
592
- }
593
- if (!fs.existsSync(target)) {
594
- throw new Error('rewrite-edn step did not produce a bb.edn');
658
+ let meta = readMetadata();
659
+ if (spec) {
660
+ const sha = await resolveRef(spec);
661
+ if (meta) {
662
+ validateExistingMetadata(meta, spec, sha);
663
+ } else {
664
+ meta = await initialize(spec, sha);
595
665
  }
596
- return { owner, project, sha };
597
- } finally {
598
- rmrf(tmp);
666
+ } else if (!meta) {
667
+ fail(`No BigConfig CLI is initialized in this directory.\n\n${usage()}`);
599
668
  }
600
- }
601
-
602
- // --- Run bb --------------------------------------------------------------
603
-
604
- function runBb(bbPath, args, javaHome, extraEnv = {}) {
605
- const env = bbEnv(javaHome, bbPath, extraEnv);
606
-
607
- const child = spawn(bbPath, args, {
608
- stdio: 'inherit',
609
- cwd: process.cwd(),
610
- env,
611
- });
612
-
613
- const forward = (sig) => {
614
- try {
615
- child.kill(sig);
616
- } catch {
617
- /* child already gone */
618
- }
619
- };
620
- process.on('SIGINT', forward);
621
- process.on('SIGTERM', forward);
622
-
623
- return new Promise((resolve) => {
624
- child.on('error', (err) => {
625
- log(`failed to start bb: ${err.message}`);
626
- resolve(127);
627
- });
628
- child.on('exit', (code, signal) => {
629
- resolve(signal ? 1 : code == null ? 1 : code);
630
- });
631
- });
632
- }
633
669
 
634
- async function main(args) {
635
- const p = resolvePlatform();
636
- // Consume the first positional argument as repo identity whenever it has
637
- // the shape "owner/project". It is never forwarded to bb: with no bb.edn it
638
- // bootstraps one, and with an existing bb.edn it validates top-level :repo.
639
- let repo = null;
640
- if (args.length && REPO_SLUG_RE.test(args[0])) {
641
- repo = args[0];
642
- args = args.slice(1);
643
- }
644
- const bbPath = await ensureBabashka(p);
645
- const javaHome = await ensureJdk(p);
646
- ensureGit();
647
- const bootstrapped = await ensureBbEdn(bbPath, javaHome, repo);
648
- const extraEnv = bootstrapped ? { BB_BOOTSTRAP_SHA: bootstrapped.sha } : {};
649
- const code = await runBb(bbPath, args, javaHome, extraEnv);
670
+ await restoreRunIfMissing(meta);
671
+ const code = await runTarget(meta, args);
650
672
  process.exit(code);
651
673
  }
652
674
 
@@ -657,12 +679,14 @@ if (require.main === module) {
657
679
  });
658
680
  }
659
681
 
660
- // Exported for tests / inspection.
661
682
  module.exports = {
683
+ parseSpec,
684
+ resolveRef,
685
+ detectTarget,
686
+ readMetadata,
687
+ validateExistingMetadata,
662
688
  resolvePlatform,
663
689
  cacheRoot,
664
690
  findJavaHome,
665
691
  ensureGit,
666
- validateBbEdnRepo,
667
- ensureBbEdn,
668
692
  };
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
- "name": "bc-pkg",
3
- "version": "1.0.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
- "bc-pkg": "bin/bc-pkg.js"
7
- },
8
- "files": ["bin/", "README.md"],
9
- "engines": {
10
- "node": ">=18"
11
- },
12
- "keywords": ["babashka", "bb", "clojure", "jdk", "cli"],
13
- "license": "MIT",
14
- "type": "commonjs"
2
+ "name": "bc-pkg",
3
+ "version": "1.0.1",
4
+ "description": "Create and run a BigConfig CLI from a language-specific GitHub package.",
5
+ "bin": {
6
+ "bc-pkg": "bin/bc-pkg.js"
7
+ },
8
+ "files": ["bin/", "README.md"],
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "keywords": ["bigconfig", "cli", "launcher", "clojure", "typescript", "python"],
13
+ "license": "MIT",
14
+ "type": "commonjs"
15
15
  }