bc-pkg 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +66 -134
  2. package/bin/bc-pkg.js +714 -422
  3. package/package.json +13 -13
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,659 @@ 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 <local-path> <args...>\n bc-pkg <args...>\n\nExamples:\n npx bc-pkg bigconfig-ai/once@typescript package validate\n npx bc-pkg ../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
+ // A normal local path: leading slash, dot-slash, or tilde. Bare names with no
94
+ // path prefix are treated as forwarded args, not local targets.
95
+ function isLocalSpec(arg) {
96
+ if (!arg) return false;
97
+ if (arg === '.' || arg === '..') return true;
98
+ if (arg.startsWith('/') || arg.startsWith('~') || arg.startsWith('./') || arg.startsWith('../')) return true;
99
+ if (process.platform === 'win32') {
100
+ if (arg.startsWith('.\\') || arg.startsWith('..\\') || arg.startsWith('\\')) return true;
101
+ if (/^[A-Za-z]:[\\/]/.test(arg)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function parseLocalSpec(arg) {
107
+ let p = arg;
108
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
109
+ p = path.join(os.homedir(), p.slice(1));
110
+ }
111
+ const abs = path.resolve(process.cwd(), p);
112
+ let resolved;
113
+ try {
114
+ resolved = fs.realpathSync(abs);
115
+ } catch {
116
+ fail(`local path ${JSON.stringify(arg)} is not a directory (${abs})`);
117
+ }
118
+ if (!fs.statSync(resolved).isDirectory()) {
119
+ fail(`local path ${JSON.stringify(arg)} is not a directory (${resolved})`);
120
+ }
121
+ return { path: resolved, name: path.basename(resolved) };
122
+ }
123
+
124
+ function ghHeaders(accept) {
125
+ const headers = {
126
+ 'user-agent': 'bc-pkg',
127
+ accept: accept || 'application/vnd.github+json',
128
+ 'x-github-api-version': '2022-11-28',
129
+ };
130
+ if (process.env.GITHUB_TOKEN) {
131
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
132
+ }
133
+ return headers;
134
+ }
135
+
136
+ async function ghFetch(url, accept) {
137
+ return fetch(url, { headers: ghHeaders(accept), redirect: 'follow' });
138
+ }
139
+
140
+ async function resolveRef(spec) {
141
+ if (FULL_SHA_RE.test(spec.ref)) {
142
+ return spec.ref.toLowerCase();
143
+ }
144
+ const url = `https://api.github.com/repos/${spec.owner}/${spec.repo}/commits/${encodeURIComponent(spec.ref)}`;
145
+ const res = await ghFetch(url);
146
+ if (res.status === 404) {
147
+ fail(`${spec.slug}@${spec.ref} not found or not accessible (set GITHUB_TOKEN for private repos)`);
148
+ }
149
+ if (!res.ok) {
150
+ fail(`GitHub API error ${res.status} resolving ${spec.slug}@${spec.ref}`);
151
+ }
152
+ const data = await res.json();
153
+ if (!data || !data.sha) fail(`${spec.slug}@${spec.ref} did not resolve to a commit`);
154
+ return String(data.sha).toLowerCase();
155
+ }
156
+
157
+ async function fetchFile(spec, sha, filePath, { required = false } = {}) {
158
+ const url = `https://api.github.com/repos/${spec.owner}/${spec.repo}/contents/${filePath}?ref=${sha}`;
159
+ const res = await ghFetch(url, 'application/vnd.github.raw');
160
+ if (res.status === 404) {
161
+ if (required) fail(`${spec.slug}@${sha.slice(0, 7)} has no ${filePath}`);
162
+ return null;
163
+ }
164
+ if (!res.ok) {
165
+ fail(`GitHub API error ${res.status} fetching ${filePath} from ${spec.slug}@${sha.slice(0, 7)}`);
166
+ }
167
+ return await res.text();
168
+ }
169
+
170
+ function parseJson(text, label) {
171
+ try {
172
+ return JSON.parse(text);
173
+ } catch (err) {
174
+ fail(`Invalid JSON in ${label}: ${err.message}`);
175
+ }
176
+ }
177
+
178
+ function parsePyProjectName(text) {
179
+ const project = sectionText(text, 'project');
180
+ const m = project && project.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
181
+ return m ? m[1] : null;
182
+ }
183
+
184
+ function sectionText(text, name) {
185
+ const re = new RegExp(`^\\s*\\[${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\s*$`, 'm');
186
+ const m = re.exec(text);
187
+ if (!m) return null;
188
+ const start = m.index + m[0].length;
189
+ const rest = text.slice(start);
190
+ const next = /^\s*\[[^\]]+\]\s*$/m.exec(rest);
191
+ return next ? rest.slice(0, next.index) : rest;
192
+ }
193
+
194
+ function detectTargetFromManifests(label, depsEdn, packageJsonText, pyprojectText, clojureCoord) {
195
+ const found = [];
196
+ if (depsEdn != null) found.push('clojure');
197
+ if (packageJsonText != null) found.push('typescript');
198
+ if (pyprojectText != null) found.push('python');
199
+ if (found.length === 0) {
200
+ fail(`${label} has no deps.edn, package.json, or pyproject.toml`);
201
+ }
202
+ if (found.length > 1) {
203
+ fail(`${label} is ambiguous; found ${found.join(', ')} manifests`);
204
+ }
205
+
206
+ if (found[0] === 'typescript') {
207
+ const pkg = parseJson(packageJsonText, 'package.json');
208
+ if (!pkg.name) fail(`${label} package.json has no name`);
209
+ return { language: 'typescript', packageName: pkg.name };
210
+ }
211
+ if (found[0] === 'python') {
212
+ const packageName = parsePyProjectName(pyprojectText);
213
+ if (!packageName) fail(`${label} pyproject.toml has no [project].name`);
214
+ return { language: 'python', packageName };
215
+ }
216
+ return { language: 'clojure', packageName: clojureCoord };
217
+ }
218
+
219
+ async function detectTarget(spec, sha) {
220
+ const [depsEdn, packageJsonText, pyprojectText] = await Promise.all([
221
+ fetchFile(spec, sha, 'deps.edn'),
222
+ fetchFile(spec, sha, 'package.json'),
223
+ fetchFile(spec, sha, 'pyproject.toml'),
224
+ ]);
225
+ return detectTargetFromManifests(
226
+ `${spec.slug}@${sha.slice(0, 7)}`,
227
+ depsEdn,
228
+ packageJsonText,
229
+ pyprojectText,
230
+ `io.github.${spec.owner}/${spec.repo}`,
231
+ );
232
+ }
233
+
234
+ function readLocalFile(local, filePath, { required = false } = {}) {
235
+ const f = path.join(local.path, filePath);
236
+ if (!fs.existsSync(f) || !fs.statSync(f).isFile()) {
237
+ if (required) fail(`${local.path} has no ${filePath}`);
238
+ return null;
239
+ }
240
+ return fs.readFileSync(f, 'utf8');
241
+ }
242
+
243
+ function detectTargetLocal(local) {
244
+ return detectTargetFromManifests(
245
+ local.path,
246
+ readLocalFile(local, 'deps.edn'),
247
+ readLocalFile(local, 'package.json'),
248
+ readLocalFile(local, 'pyproject.toml'),
249
+ `local/${local.name}`,
250
+ );
251
+ }
252
+
253
+ // --- native metadata -----------------------------------------------------
254
+
255
+ function metadataFromPackageJson(file) {
256
+ if (!fs.existsSync(file)) return null;
257
+ const pkg = parseJson(fs.readFileSync(file, 'utf8'), file);
258
+ if (!pkg.bigconfig) return null;
259
+ return { ...pkg.bigconfig, language: pkg.bigconfig.language || 'typescript', manifest: file };
260
+ }
261
+
262
+ function metadataFromPyproject(file) {
263
+ if (!fs.existsSync(file)) return null;
264
+ const text = fs.readFileSync(file, 'utf8');
265
+ const sec = sectionText(text, 'tool.bigconfig');
266
+ if (!sec) return null;
267
+ const get = (key) => {
268
+ const m = sec.match(new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*["']([^"']+)["']`, 'm'));
269
+ return m ? m[1] : undefined;
270
+ };
271
+ return {
272
+ repo: get('repo'),
273
+ ref: get('ref'),
274
+ sha: get('sha'),
275
+ language: get('language') || 'python',
276
+ run: get('run') || 'run',
277
+ packageName: get('package-name'),
278
+ local: /^\s*local\s*=\s*true\s*$/m.test(sec),
279
+ path: get('path'),
280
+ manifest: file,
281
+ };
282
+ }
283
+
284
+ function metadataFromDepsEdn(file) {
285
+ if (!fs.existsSync(file)) return null;
286
+ const text = fs.readFileSync(file, 'utf8');
287
+ if (!text.includes(':bigconfig/repo') && !text.includes(':bigconfig/path')) return null;
288
+ const get = (key) => {
289
+ const m = text.match(new RegExp(`:${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+"([^"]+)"`));
290
+ return m ? m[1] : undefined;
291
+ };
292
+ return {
293
+ repo: get('bigconfig/repo'),
294
+ ref: get('bigconfig/ref'),
295
+ sha: get('bigconfig/sha'),
296
+ language: get('bigconfig/language') || 'clojure',
297
+ run: get('bigconfig/run') || 'run',
298
+ local: get('bigconfig/local') === 'true',
299
+ path: get('bigconfig/path'),
300
+ manifest: file,
301
+ };
302
+ }
303
+
304
+ function readMetadata(cwd = process.cwd()) {
305
+ const metas = [
306
+ metadataFromDepsEdn(path.join(cwd, 'deps.edn')),
307
+ metadataFromPackageJson(path.join(cwd, 'package.json')),
308
+ metadataFromPyproject(path.join(cwd, 'pyproject.toml')),
309
+ ].filter(Boolean);
310
+ if (metas.length > 1) {
311
+ fail('Multiple BigConfig metadata files found; keep only one of deps.edn, package.json, or pyproject.toml initialized for bc-pkg.');
312
+ }
313
+ if (metas.length === 0) return null;
314
+ const meta = metas[0];
315
+ if (meta.local) {
316
+ if (!meta.path || !meta.language) fail(`Incomplete BigConfig metadata in ${meta.manifest}`);
317
+ } else if (!meta.repo || !meta.ref || !meta.sha || !meta.language) {
318
+ fail(`Incomplete BigConfig metadata in ${meta.manifest}`);
319
+ }
320
+ return meta;
321
+ }
322
+
323
+ function validateExistingMetadata(meta, spec, sha) {
324
+ if (meta.local) {
325
+ fail('Current directory is initialized for a local BigConfig package; refusing to switch to a GitHub package.');
326
+ }
327
+ const expectedRepo = spec.slug;
328
+ const problems = [];
329
+ if (meta.repo !== expectedRepo) problems.push(`repo ${JSON.stringify(meta.repo)} != ${JSON.stringify(expectedRepo)}`);
330
+ if (meta.ref !== spec.ref) problems.push(`ref ${JSON.stringify(meta.ref)} != ${JSON.stringify(spec.ref)}`);
331
+ if (String(meta.sha).toLowerCase() !== sha.toLowerCase()) problems.push(`sha ${meta.sha} != ${sha}`);
332
+ if (problems.length) {
333
+ fail(`Current directory is already initialized for a different BigConfig package:\n ${problems.join('\n ')}`);
334
+ }
335
+ }
336
+
337
+ function validateExistingLocalMetadata(meta, local) {
338
+ if (!meta.local) {
339
+ fail('Current directory is initialized for a GitHub BigConfig package; refusing to switch to a local package.');
340
+ }
341
+ let existing = null;
342
+ if (meta.path) {
343
+ try {
344
+ existing = fs.realpathSync(meta.path);
345
+ } catch {
346
+ existing = path.resolve(meta.path);
347
+ }
348
+ }
349
+ if (existing !== local.path) {
350
+ fail(`Current directory is already initialized for a different local BigConfig package:\n path ${JSON.stringify(meta.path)} != ${JSON.stringify(local.path)}`);
351
+ }
352
+ }
353
+
354
+ function quoteToml(s) {
355
+ return JSON.stringify(String(s));
356
+ }
357
+
358
+ function writeRunFile(text) {
359
+ const target = path.join(process.cwd(), 'run');
360
+ fs.writeFileSync(target, text);
361
+ if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
362
+ }
363
+
364
+ // Local targets symlink the run file so edits to the local package flow through;
365
+ // copy as a fallback where symlinks are unavailable.
366
+ function linkRunFile(source) {
367
+ const target = path.join(process.cwd(), 'run');
368
+ try {
369
+ fs.lstatSync(target);
370
+ fs.rmSync(target, { force: true });
371
+ } catch {
372
+ // nothing to remove
373
+ }
374
+ try {
375
+ fs.symlinkSync(source, target);
376
+ } catch {
377
+ fs.copyFileSync(source, target);
378
+ if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
379
+ }
380
+ }
381
+
382
+ function clojureCoord(spec) {
383
+ return `io.github.${spec.owner}/${spec.repo}`;
384
+ }
385
+
386
+ function writeClojureManifest(spec, sha, target) {
387
+ const coord = clojureCoord(spec);
388
+ const gitUrl = `https://github.com/${spec.owner}/${spec.repo}.git`;
389
+ 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`;
390
+ fs.writeFileSync(path.join(process.cwd(), 'deps.edn'), deps);
391
+
392
+ // Babashka script execution reads bb.edn, not deps.edn. Metadata remains in
393
+ // deps.edn per the launcher contract; bb.edn is the runtime dependency file.
394
+ const bb = `{:deps {${coord} {:git/url "${gitUrl}"\n :git/sha "${sha}"}}}\n`;
395
+ fs.writeFileSync(path.join(process.cwd(), 'bb.edn'), bb);
396
+ }
397
+
398
+ function writeTypeScriptManifest(spec, sha, target) {
399
+ const file = path.join(process.cwd(), 'package.json');
400
+ let pkg = {};
401
+ if (fs.existsSync(file)) {
402
+ pkg = parseJson(fs.readFileSync(file, 'utf8'), file);
403
+ if (pkg.bigconfig) validateExistingMetadata(pkg.bigconfig, spec, sha);
404
+ }
405
+ pkg.type = pkg.type || 'module';
406
+ pkg.scripts = { ...(pkg.scripts || {}), run: 'node run' };
407
+ pkg.dependencies = { ...(pkg.dependencies || {}) };
408
+ pkg.dependencies[target.packageName] = `github:${spec.owner}/${spec.repo}#${sha}`;
409
+ pkg.bigconfig = {
410
+ repo: spec.slug,
411
+ ref: spec.ref,
412
+ sha,
413
+ language: 'typescript',
414
+ run: 'run',
415
+ packageName: target.packageName,
416
+ };
417
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
418
+ }
419
+
420
+ function writePythonManifest(spec, sha, target) {
421
+ const file = path.join(process.cwd(), 'pyproject.toml');
422
+ if (fs.existsSync(file)) {
423
+ const existing = metadataFromPyproject(file);
424
+ if (!existing) {
425
+ fail('pyproject.toml already exists and is not initialized for bc-pkg; refusing to rewrite it.');
426
+ }
427
+ validateExistingMetadata(existing, spec, sha);
428
+ }
429
+ const dep = `${target.packageName} @ git+https://github.com/${spec.owner}/${spec.repo}.git@${sha}`;
430
+ 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`;
431
+ fs.writeFileSync(file, text);
432
+ }
433
+
434
+ function writeNativeManifest(spec, sha, target) {
435
+ if (target.language === 'clojure') return writeClojureManifest(spec, sha, target);
436
+ if (target.language === 'typescript') return writeTypeScriptManifest(spec, sha, target);
437
+ if (target.language === 'python') return writePythonManifest(spec, sha, target);
438
+ fail(`Unsupported language: ${target.language}`);
439
+ }
440
+
441
+ // --- local (live) manifests ----------------------------------------------
442
+
443
+ function writeClojureManifestLocal(local, target) {
444
+ const coord = target.packageName || `local/${local.name}`;
445
+ const p = local.path;
446
+ const deps = `{:deps {${coord} {:local/root "${p}"}}\n :bigconfig/local "true"\n :bigconfig/path "${p}"\n :bigconfig/language "clojure"\n :bigconfig/run "run"}\n`;
447
+ fs.writeFileSync(path.join(process.cwd(), 'deps.edn'), deps);
448
+
449
+ // Babashka reads bb.edn; metadata stays in deps.edn per the launcher contract.
450
+ const bb = `{:deps {${coord} {:local/root "${p}"}}}\n`;
451
+ fs.writeFileSync(path.join(process.cwd(), 'bb.edn'), bb);
452
+ }
453
+
454
+ function writeTypeScriptManifestLocal(local, target) {
455
+ const file = path.join(process.cwd(), 'package.json');
456
+ let pkg = {};
457
+ if (fs.existsSync(file)) {
458
+ pkg = parseJson(fs.readFileSync(file, 'utf8'), file);
459
+ if (pkg.bigconfig) validateExistingLocalMetadata(metadataFromPackageJson(file), local);
460
+ }
461
+ const packageName = target.packageName || local.name;
462
+ pkg.type = pkg.type || 'module';
463
+ pkg.scripts = { ...(pkg.scripts || {}), run: 'node run' };
464
+ pkg.dependencies = { ...(pkg.dependencies || {}) };
465
+ pkg.dependencies[packageName] = `file:${local.path}`;
466
+ pkg.bigconfig = {
467
+ local: true,
468
+ path: local.path,
469
+ language: 'typescript',
470
+ run: 'run',
471
+ packageName,
472
+ };
473
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
474
+ }
475
+
476
+ function writePythonManifestLocal(local, target) {
477
+ const file = path.join(process.cwd(), 'pyproject.toml');
478
+ if (fs.existsSync(file)) {
479
+ const existing = metadataFromPyproject(file);
480
+ if (!existing) {
481
+ fail('pyproject.toml already exists and is not initialized for bc-pkg; refusing to rewrite it.');
482
+ }
483
+ validateExistingLocalMetadata(existing, local);
484
+ }
485
+ const packageName = target.packageName || local.name;
486
+ const text = `[project]\nname = "bigconfig-cli"\nversion = "0.1.0"\nrequires-python = ">=3.12"\ndependencies = [\n ${quoteToml(packageName)},\n]\n\n[tool.uv.sources]\n${quoteToml(packageName)} = { path = ${quoteToml(local.path)}, editable = true }\n\n[tool.bigconfig]\nlocal = true\npath = ${quoteToml(local.path)}\nlanguage = "python"\nrun = "run"\npackage-name = ${quoteToml(packageName)}\n`;
487
+ fs.writeFileSync(file, text);
488
+ }
489
+
490
+ function writeNativeManifestLocal(local, target) {
491
+ if (target.language === 'clojure') return writeClojureManifestLocal(local, target);
492
+ if (target.language === 'typescript') return writeTypeScriptManifestLocal(local, target);
493
+ if (target.language === 'python') return writePythonManifestLocal(local, target);
494
+ fail(`Unsupported language: ${target.language}`);
495
+ }
496
+
497
+ // --- target dependency setup and execution -------------------------------
498
+
499
+ function pythonSitePackagesDirs() {
500
+ const venv = path.join(process.cwd(), '.venv');
501
+ const candidates = [path.join(venv, 'Lib', 'site-packages')];
502
+ for (const libName of ['lib', 'lib64']) {
503
+ const libDir = path.join(venv, libName);
504
+ let entries = [];
505
+ try {
506
+ entries = fs.readdirSync(libDir);
507
+ } catch {
508
+ entries = [];
509
+ }
510
+ for (const e of entries.sort()) {
511
+ if (e.startsWith('python')) candidates.push(path.join(libDir, e, 'site-packages'));
512
+ }
513
+ }
514
+ const seen = new Set();
515
+ const result = [];
516
+ for (const c of candidates) {
517
+ if (seen.has(c)) continue;
518
+ seen.add(c);
519
+ try {
520
+ if (fs.statSync(c).isDirectory()) result.push(c);
521
+ } catch {
522
+ // not present
523
+ }
524
+ }
525
+ return result;
526
+ }
527
+
528
+ // Python wheels install templates as top-level package data under
529
+ // site-packages/resources; BigConfig's renderer resolves them from ./resources.
530
+ // For editable local installs the source tree is used instead (resources live
531
+ // under <path>/src/resources or <path>/resources, never in site-packages).
532
+ function exposePythonResources(meta) {
533
+ const target = path.join(process.cwd(), 'resources');
534
+ try {
535
+ if (fs.statSync(target)) return;
536
+ } catch {
537
+ // not present; continue
538
+ }
539
+ try {
540
+ if (fs.lstatSync(target)) fs.rmSync(target, { force: true, recursive: true });
541
+ } catch {
542
+ // nothing to remove (e.g. broken symlink already gone)
543
+ }
544
+ const candidates = [];
545
+ if (meta && meta.local && meta.path) {
546
+ candidates.push(path.join(meta.path, 'src', 'resources'), path.join(meta.path, 'resources'));
547
+ }
548
+ for (const site of pythonSitePackagesDirs()) candidates.push(path.join(site, 'resources'));
549
+ let source = null;
550
+ for (const c of candidates) {
551
+ try {
552
+ if (fs.statSync(c).isDirectory()) {
553
+ source = c;
554
+ break;
555
+ }
556
+ } catch {
557
+ // not a dir
558
+ }
559
+ }
560
+ if (!source) return;
561
+ try {
562
+ fs.symlinkSync(source, target, 'dir');
563
+ } catch {
564
+ fs.cpSync(source, target, { recursive: true });
565
+ }
566
+ }
567
+
568
+ async function ensureTargetDeps(meta) {
569
+ if (meta.language === 'typescript') {
570
+ requireCommand('node', 'Install Node.js and try again.');
571
+ requireCommand('npm', 'Install npm and try again.');
572
+ if (!fs.existsSync(path.join(process.cwd(), 'node_modules'))) {
573
+ log('Installing TypeScript target dependencies with npm install...');
574
+ const code = await runCommand('npm', ['install']);
575
+ if (code !== 0) process.exit(code);
576
+ }
577
+ return;
578
+ }
579
+ if (meta.language === 'python') {
580
+ const py = whichPython();
581
+ if (!py) fail('python3 or python is required but was not found on PATH.');
582
+ requireCommand('uv', 'Install uv and try again.');
583
+ if (!fs.existsSync(path.join(process.cwd(), '.venv'))) {
584
+ log('Installing Python target dependencies with uv sync...');
585
+ const code = await runCommand('uv', ['sync']);
586
+ if (code !== 0) process.exit(code);
587
+ }
588
+ exposePythonResources(meta);
589
+ return;
590
+ }
591
+ }
592
+
593
+ async function runTarget(meta, args) {
594
+ if (meta.language === 'typescript') {
595
+ await ensureTargetDeps(meta);
596
+ return await runCommand('node', ['run', ...args]);
597
+ }
598
+ if (meta.language === 'python') {
599
+ await ensureTargetDeps(meta);
600
+ return await runCommand('uv', ['run', 'python', meta.run || 'run', ...args]);
601
+ }
602
+ if (meta.language === 'clojure') {
603
+ const p = resolvePlatform();
604
+ const bbPath = await ensureBabashka(p);
605
+ const javaHome = await ensureJdk(p);
606
+ ensureGit();
607
+ return await runBb(bbPath, ['run', ...args], javaHome);
608
+ }
609
+ fail(`Unsupported language: ${meta.language}`);
610
+ }
611
+
612
+ async function initialize(spec, sha) {
613
+ const target = await detectTarget(spec, sha);
614
+ const runText = await fetchFile(spec, sha, 'run', { required: true });
615
+ writeRunFile(runText);
616
+ writeNativeManifest(spec, sha, target);
617
+ return {
618
+ repo: spec.slug,
619
+ ref: spec.ref,
620
+ sha,
621
+ language: target.language,
622
+ run: 'run',
623
+ packageName: target.packageName,
624
+ };
625
+ }
626
+
627
+ function initializeLocal(local) {
628
+ if (local.path === fs.realpathSync(process.cwd())) {
629
+ fail("local path is the current directory; run bc-pkg from a separate directory so it does not overwrite the package's own manifest.");
630
+ }
631
+ const target = detectTargetLocal(local);
632
+ const runSource = path.join(local.path, 'run');
633
+ if (!fs.existsSync(runSource) || !fs.statSync(runSource).isFile()) fail(`${local.path} has no run`);
634
+ linkRunFile(runSource);
635
+ writeNativeManifestLocal(local, target);
636
+ return {
637
+ repo: null,
638
+ ref: null,
639
+ sha: null,
640
+ language: target.language,
641
+ run: 'run',
642
+ packageName: target.packageName,
643
+ local: true,
644
+ path: local.path,
645
+ };
646
+ }
647
+
648
+ async function restoreRunIfMissing(meta) {
649
+ const runPath = path.join(process.cwd(), meta.run || 'run');
650
+ if (fs.existsSync(runPath)) return;
651
+ if (meta.local) {
652
+ const source = path.join(meta.path, meta.run || 'run');
653
+ if (!fs.existsSync(source)) fail(`${meta.path} has no ${meta.run || 'run'}`);
654
+ linkRunFile(source);
655
+ return;
656
+ }
657
+ const [owner, repo] = meta.repo.split('/');
658
+ const spec = { owner, repo, slug: meta.repo, ref: meta.ref };
659
+ const runText = await fetchFile(spec, meta.sha, 'run', { required: true });
660
+ writeRunFile(runText);
661
+ }
662
+
663
+ // --- Babashka/JDK bootstrap for Clojure targets --------------------------
27
664
 
28
- // Maps the host OS/arch to babashka release asset + Adoptium API parameters.
29
665
  function resolvePlatform() {
30
- const plat = process.platform; // 'darwin' | 'linux' | 'win32'
31
- const arch = process.arch; // 'arm64' | 'x64'
666
+ const plat = process.platform;
667
+ const arch = process.arch;
32
668
  const exeSuffix = plat === 'win32' ? '.exe' : '';
33
669
 
34
670
  let bbOs;
@@ -47,7 +683,7 @@ function resolvePlatform() {
47
683
  jdkOs = 'windows';
48
684
  archiveExt = 'zip';
49
685
  } else {
50
- throw new Error(`Unsupported OS: ${plat}`);
686
+ fail(`Unsupported OS: ${plat}`);
51
687
  }
52
688
 
53
689
  let bbArch;
@@ -59,17 +695,11 @@ function resolvePlatform() {
59
695
  bbArch = 'amd64';
60
696
  jdkArch = 'x64';
61
697
  } else {
62
- throw new Error(`Unsupported CPU architecture: ${arch}`);
698
+ fail(`Unsupported CPU architecture: ${arch}`);
63
699
  }
64
700
 
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
- }
701
+ const bbArchToken = plat === 'linux' && arch === 'arm64' ? 'aarch64-static' : bbArch;
702
+ if (plat === 'win32' && arch === 'arm64') fail('babashka has no prebuilt Windows arm64 binary');
73
703
 
74
704
  return {
75
705
  exeSuffix,
@@ -84,22 +714,16 @@ function resolvePlatform() {
84
714
  }
85
715
 
86
716
  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');
717
+ const base = process.platform === 'win32'
718
+ ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
719
+ : process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
91
720
  return path.join(base, 'bc-pkg');
92
721
  }
93
722
 
94
- // --- Filesystem helpers --------------------------------------------------
95
-
96
723
  function rmrf(target) {
97
724
  fs.rmSync(target, { recursive: true, force: true });
98
725
  }
99
726
 
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
727
  async function installOnce(finalDir, install) {
104
728
  if (fs.existsSync(finalDir)) return;
105
729
  const tmp = `${finalDir}.tmp-${process.pid}-${Date.now()}`;
@@ -107,7 +731,7 @@ async function installOnce(finalDir, install) {
107
731
  try {
108
732
  await install(tmp);
109
733
  if (fs.existsSync(finalDir)) {
110
- rmrf(tmp); // another process won the race
734
+ rmrf(tmp);
111
735
  return;
112
736
  }
113
737
  fs.mkdirSync(path.dirname(finalDir), { recursive: true });
@@ -127,52 +751,28 @@ async function installOnce(finalDir, install) {
127
751
  }
128
752
 
129
753
  async function download(url, destFile) {
130
- const res = await fetch(url, {
131
- redirect: 'follow',
132
- headers: { 'user-agent': 'bc-pkg' },
133
- });
754
+ const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'bc-pkg' } });
134
755
  if (!res.ok || !res.body) {
135
- throw new Error(
136
- `Download failed (HTTP ${res.status} ${res.statusText})\n ${url}`
137
- );
756
+ fail(`Download failed (HTTP ${res.status} ${res.statusText})\n ${url}`);
138
757
  }
139
758
  await fs.promises.mkdir(path.dirname(destFile), { recursive: true });
140
759
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destFile));
141
760
  }
142
761
 
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
762
  function extract(archive, destDir) {
147
763
  fs.mkdirSync(destDir, { recursive: true });
148
- let r = spawnSync('tar', ['-xf', archive, '-C', destDir], {
149
- stdio: ['ignore', 'inherit', 'inherit'],
150
- });
764
+ let r = spawnSync('tar', ['-xf', archive, '-C', destDir], { stdio: ['ignore', 'inherit', 'inherit'] });
151
765
  if (r.error && r.error.code === 'ENOENT') {
152
766
  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
- }
767
+ r = spawnSync('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archive}' -DestinationPath '${destDir}' -Force`], { stdio: ['ignore', 'inherit', 'inherit'] });
768
+ if (r.status !== 0) fail(`Failed to extract ${archive} (PowerShell fallback)`);
165
769
  return;
166
770
  }
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})`);
771
+ fail(`'tar' not found on PATH; cannot extract ${archive}`);
171
772
  }
773
+ if (r.status !== 0) fail(`Failed to extract ${archive} (tar exit ${r.status})`);
172
774
  }
173
775
 
174
- // Locates JAVA_HOME inside an extracted JDK. Layout differs per OS
175
- // (linux: <root>/bin/java, macOS: <root>/Contents/Home/bin/java).
176
776
  function findJavaHome(root, exeSuffix) {
177
777
  const javaRel = path.join('bin', `java${exeSuffix}`);
178
778
  const stack = [root];
@@ -185,15 +785,11 @@ function findJavaHome(root, exeSuffix) {
185
785
  } catch {
186
786
  continue;
187
787
  }
188
- for (const e of entries) {
189
- if (e.isDirectory()) stack.push(path.join(dir, e.name));
190
- }
788
+ for (const e of entries) if (e.isDirectory()) stack.push(path.join(dir, e.name));
191
789
  }
192
790
  return null;
193
791
  }
194
792
 
195
- // --- Bootstrap steps -----------------------------------------------------
196
-
197
793
  async function ensureBabashka(p) {
198
794
  const version = DEFAULT_BB_VERSION;
199
795
  const finalDir = path.join(cacheRoot(), 'bb', version);
@@ -212,23 +808,13 @@ async function ensureBabashka(p) {
212
808
  extract(archive, tmp);
213
809
  fs.unlinkSync(archive);
214
810
  const exe = path.join(tmp, `bb${p.exeSuffix}`);
215
- if (!fs.existsSync(exe)) {
216
- throw new Error('babashka binary not found after extraction');
217
- }
811
+ if (!fs.existsSync(exe)) fail('babashka binary not found after extraction');
218
812
  if (process.platform !== 'win32') fs.chmodSync(exe, 0o755);
219
813
  });
220
814
 
221
- if (!fs.existsSync(bbPath)) {
222
- throw new Error(
223
- `babashka cache looks corrupt; remove ${finalDir} and retry`
224
- );
225
- }
815
+ if (!fs.existsSync(bbPath)) fail(`babashka cache looks corrupt; remove ${finalDir} and retry`);
226
816
  if (process.platform !== 'win32') {
227
- try {
228
- fs.chmodSync(bbPath, 0o755);
229
- } catch {
230
- /* already executable */
231
- }
817
+ try { fs.chmodSync(bbPath, 0o755); } catch {}
232
818
  }
233
819
  return bbPath;
234
820
  }
@@ -240,9 +826,7 @@ async function ensureJdk(p) {
240
826
 
241
827
  await installOnce(finalDir, async (tmp) => {
242
828
  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`;
829
+ const url = `https://api.adoptium.net/v3/binary/latest/${feature}/ga/${p.jdkOs}/${p.jdkArch}/jdk/hotspot/normal/eclipse`;
246
830
  log(`Installing Temurin JDK ${feature} (set JDK_VERSION to override)...`);
247
831
  try {
248
832
  await download(url, archive);
@@ -252,8 +836,7 @@ async function ensureJdk(p) {
252
836
  extract(archive, tmp);
253
837
  fs.unlinkSync(archive);
254
838
  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.
839
+ if (!home) fail('could not locate java in extracted JDK');
257
840
  fs.writeFileSync(path.join(tmp, '.javahome'), path.relative(tmp, home));
258
841
  });
259
842
 
@@ -263,75 +846,33 @@ async function ensureJdk(p) {
263
846
  } catch {
264
847
  javaHome = null;
265
848
  }
266
- if (
267
- !javaHome ||
268
- !fs.existsSync(path.join(javaHome, 'bin', `java${p.exeSuffix}`))
269
- ) {
849
+ if (!javaHome || !fs.existsSync(path.join(javaHome, 'bin', `java${p.exeSuffix}`))) {
270
850
  javaHome = findJavaHome(finalDir, p.exeSuffix);
271
851
  }
272
- if (!javaHome) {
273
- throw new Error(`JDK cache looks corrupt; remove ${finalDir} and retry`);
274
- }
852
+ if (!javaHome) fail(`JDK cache looks corrupt; remove ${finalDir} and retry`);
275
853
  return javaHome;
276
854
  }
277
855
 
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
856
  function ensureGit() {
293
857
  if (process.platform !== 'linux') return;
294
858
  if (commandWorks('git', ['--version'])) return;
295
859
 
296
- const isRoot =
297
- typeof process.getuid === 'function' && process.getuid() === 0;
860
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
298
861
  const sudo = isRoot ? [] : binExists('sudo') ? ['sudo'] : null;
299
862
  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
- );
863
+ fail('git is missing and cannot be installed: not running as root and `sudo` is unavailable.\n Install git manually and re-run.');
305
864
  }
306
865
 
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
866
  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
- },
867
+ { bin: 'apt-get', steps: [['apt-get', 'update', '-y'], ['apt-get', 'install', '-y', 'git']], soft: [0] },
318
868
  { bin: 'dnf', steps: [['dnf', 'install', '-y', 'git']] },
319
869
  { bin: 'yum', steps: [['yum', 'install', '-y', 'git']] },
320
- {
321
- bin: 'zypper',
322
- steps: [['zypper', '--non-interactive', 'install', 'git']],
323
- },
870
+ { bin: 'zypper', steps: [['zypper', '--non-interactive', 'install', 'git']] },
324
871
  { bin: 'pacman', steps: [['pacman', '-S', '--noconfirm', 'git']] },
325
872
  { bin: 'apk', steps: [['apk', 'add', '--no-cache', 'git']] },
326
873
  ];
327
874
  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
- }
875
+ if (!pm) fail('git is missing and no supported package manager was found. Install git manually and re-run.');
335
876
 
336
877
  log(`Installing git via ${pm.bin}${sudo.length ? ' (sudo)' : ''}...`);
337
878
  const env = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
@@ -341,18 +882,12 @@ function ensureGit() {
341
882
  const ok = !r.error && r.status === 0;
342
883
  if (!ok && !(pm.soft && pm.soft.includes(i))) {
343
884
  const why = r.error ? r.error.code : `exit ${r.status}`;
344
- throw new Error(`git install failed: \`${argv.join(' ')}\` (${why}).`);
885
+ fail(`git install failed: \`${argv.join(' ')}\` (${why}).`);
345
886
  }
346
887
  });
347
-
348
- if (!commandWorks('git', ['--version'])) {
349
- throw new Error('git still not available after the install attempt.');
350
- }
888
+ if (!commandWorks('git', ['--version'])) fail('git still not available after the install attempt.');
351
889
  }
352
890
 
353
- // --- bb.edn bootstrap ----------------------------------------------------
354
-
355
- // Env augmented so spawned processes find the cached JDK and bb; nothing system-wide.
356
891
  function bbEnv(javaHome, bbPath, extraEnv = {}) {
357
892
  const env = { ...process.env, ...extraEnv };
358
893
  const pathEntries = [];
@@ -360,293 +895,44 @@ function bbEnv(javaHome, bbPath, extraEnv = {}) {
360
895
  env.JAVA_HOME = javaHome;
361
896
  pathEntries.push(path.join(javaHome, 'bin'));
362
897
  }
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
- }
898
+ if (bbPath) pathEntries.push(path.dirname(bbPath));
899
+ if (pathEntries.length) env.PATH = pathEntries.join(path.delimiter) + path.delimiter + (env.PATH || '');
369
900
  return env;
370
901
  }
371
902
 
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
- }
903
+ function runBb(bbPath, args, javaHome, extraEnv = {}) {
904
+ return runCommand(bbPath, args, { env: bbEnv(javaHome, bbPath, extraEnv) });
504
905
  }
505
906
 
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}")`);
907
+ // --- main ----------------------------------------------------------------
908
+
909
+ async function main(argv) {
910
+ let args = [...argv];
911
+ const first = args.length ? args[0] : null;
912
+ const localSpec = first != null && isLocalSpec(first) ? parseLocalSpec(first) : null;
913
+ const spec = localSpec ? null : first ? parseSpec(first) : null;
914
+ if (localSpec || spec) args = args.slice(1);
915
+
916
+ let meta = readMetadata();
917
+ if (localSpec) {
918
+ if (meta) {
919
+ validateExistingLocalMetadata(meta, localSpec);
920
+ } else {
921
+ meta = initializeLocal(localSpec);
518
922
  }
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`);
546
-
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();
558
-
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
- );
923
+ } else if (spec) {
924
+ const sha = await resolveRef(spec);
925
+ if (meta) {
926
+ validateExistingMetadata(meta, spec, sha);
927
+ } else {
928
+ meta = await initialize(spec, sha);
592
929
  }
593
- if (!fs.existsSync(target)) {
594
- throw new Error('rewrite-edn step did not produce a bb.edn');
595
- }
596
- return { owner, project, sha };
597
- } finally {
598
- rmrf(tmp);
930
+ } else if (!meta) {
931
+ fail(`No BigConfig CLI is initialized in this directory.\n\n${usage()}`);
599
932
  }
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
933
 
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);
934
+ await restoreRunIfMissing(meta);
935
+ const code = await runTarget(meta, args);
650
936
  process.exit(code);
651
937
  }
652
938
 
@@ -657,12 +943,18 @@ if (require.main === module) {
657
943
  });
658
944
  }
659
945
 
660
- // Exported for tests / inspection.
661
946
  module.exports = {
947
+ parseSpec,
948
+ isLocalSpec,
949
+ parseLocalSpec,
950
+ resolveRef,
951
+ detectTarget,
952
+ detectTargetLocal,
953
+ readMetadata,
954
+ validateExistingMetadata,
955
+ validateExistingLocalMetadata,
662
956
  resolvePlatform,
663
957
  cacheRoot,
664
958
  findJavaHome,
665
959
  ensureGit,
666
- validateBbEdnRepo,
667
- ensureBbEdn,
668
960
  };