bc-pkg 1.0.1 → 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 +30 -0
  2. package/bin/bc-pkg.js +286 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,6 +24,36 @@ npx bc-pkg bigconfig-ai/once@2f4e8c0d0b4c4b8f0c3a9f6e2a1b5c7d8e9f0123 package va
24
24
  On the first run, `bc-pkg` resolves the ref to a full SHA and pins it. Later
25
25
  runs omit `<owner/repo@ref>` and keep using the pinned SHA.
26
26
 
27
+ ## Local repositories
28
+
29
+ For live local development you can point `bc-pkg` at a local checkout of a
30
+ target package instead of a GitHub spec. The first argument is treated as a
31
+ local path when it starts with `/`, `./`, `../`, `~`, or is `.`/`..`:
32
+
33
+ ```sh
34
+ npx bc-pkg ../once/typescript package build
35
+ npx bc-pkg /abs/path/to/once/clojure package build
36
+ ```
37
+
38
+ Local targets are wired up **live** — your uncommitted edits in the local
39
+ package are picked up on the next run, with no SHA pinning and no push:
40
+
41
+ | Target language | Local dependency |
42
+ | --- | --- |
43
+ | Clojure | `deps.edn` / `bb.edn` use `:local/root` |
44
+ | TypeScript | `package.json` uses a `file:` dependency |
45
+ | Python | `pyproject.toml` uses an editable `[tool.uv.sources]` path |
46
+
47
+ The `run` file is symlinked (not copied) so run-file edits are also live. Run
48
+ `bc-pkg` from a **separate** directory; pointing it at the current directory is
49
+ refused so it never overwrites the package's own manifest. Switching an
50
+ initialized directory between local and GitHub (or to a different local path) is
51
+ a hard error, just like a repo/ref/SHA mismatch.
52
+
53
+ Notes: TypeScript local dev requires the local package to be built (its
54
+ `dist/`); Python local dev installs the package editable and exposes its
55
+ `resources/` from the source tree.
56
+
27
57
  ## What is created
28
58
 
29
59
  The launcher copies the target package's root `run` file into the current
package/bin/bc-pkg.js CHANGED
@@ -27,7 +27,7 @@ function fail(msg) {
27
27
  }
28
28
 
29
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`;
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
31
  }
32
32
 
33
33
  // --- generic process helpers -------------------------------------------
@@ -90,6 +90,37 @@ function parseSpec(arg) {
90
90
  return { owner: m[1], repo: m[2], ref: m[3], slug: `${m[1]}/${m[2]}` };
91
91
  }
92
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
+
93
124
  function ghHeaders(accept) {
94
125
  const headers = {
95
126
  'user-agent': 'bc-pkg',
@@ -160,35 +191,63 @@ function sectionText(text, name) {
160
191
  return next ? rest.slice(0, next.index) : rest;
161
192
  }
162
193
 
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
-
194
+ function detectTargetFromManifests(label, depsEdn, packageJsonText, pyprojectText, clojureCoord) {
170
195
  const found = [];
171
196
  if (depsEdn != null) found.push('clojure');
172
197
  if (packageJsonText != null) found.push('typescript');
173
198
  if (pyprojectText != null) found.push('python');
174
199
  if (found.length === 0) {
175
- fail(`${spec.slug}@${sha.slice(0, 7)} has no deps.edn, package.json, or pyproject.toml`);
200
+ fail(`${label} has no deps.edn, package.json, or pyproject.toml`);
176
201
  }
177
202
  if (found.length > 1) {
178
- fail(`${spec.slug}@${sha.slice(0, 7)} is ambiguous; found ${found.join(', ')} manifests`);
203
+ fail(`${label} is ambiguous; found ${found.join(', ')} manifests`);
179
204
  }
180
205
 
181
206
  if (found[0] === 'typescript') {
182
207
  const pkg = parseJson(packageJsonText, 'package.json');
183
- if (!pkg.name) fail(`${spec.slug}@${sha.slice(0, 7)} package.json has no name`);
208
+ if (!pkg.name) fail(`${label} package.json has no name`);
184
209
  return { language: 'typescript', packageName: pkg.name };
185
210
  }
186
211
  if (found[0] === 'python') {
187
212
  const packageName = parsePyProjectName(pyprojectText);
188
- if (!packageName) fail(`${spec.slug}@${sha.slice(0, 7)} pyproject.toml has no [project].name`);
213
+ if (!packageName) fail(`${label} pyproject.toml has no [project].name`);
189
214
  return { language: 'python', packageName };
190
215
  }
191
- return { language: 'clojure', packageName: `io.github.${spec.owner}/${spec.repo}` };
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
+ );
192
251
  }
193
252
 
194
253
  // --- native metadata -----------------------------------------------------
@@ -216,6 +275,8 @@ function metadataFromPyproject(file) {
216
275
  language: get('language') || 'python',
217
276
  run: get('run') || 'run',
218
277
  packageName: get('package-name'),
278
+ local: /^\s*local\s*=\s*true\s*$/m.test(sec),
279
+ path: get('path'),
219
280
  manifest: file,
220
281
  };
221
282
  }
@@ -223,7 +284,7 @@ function metadataFromPyproject(file) {
223
284
  function metadataFromDepsEdn(file) {
224
285
  if (!fs.existsSync(file)) return null;
225
286
  const text = fs.readFileSync(file, 'utf8');
226
- if (!text.includes(':bigconfig/repo')) return null;
287
+ if (!text.includes(':bigconfig/repo') && !text.includes(':bigconfig/path')) return null;
227
288
  const get = (key) => {
228
289
  const m = text.match(new RegExp(`:${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+"([^"]+)"`));
229
290
  return m ? m[1] : undefined;
@@ -234,6 +295,8 @@ function metadataFromDepsEdn(file) {
234
295
  sha: get('bigconfig/sha'),
235
296
  language: get('bigconfig/language') || 'clojure',
236
297
  run: get('bigconfig/run') || 'run',
298
+ local: get('bigconfig/local') === 'true',
299
+ path: get('bigconfig/path'),
237
300
  manifest: file,
238
301
  };
239
302
  }
@@ -249,13 +312,18 @@ function readMetadata(cwd = process.cwd()) {
249
312
  }
250
313
  if (metas.length === 0) return null;
251
314
  const meta = metas[0];
252
- if (!meta.repo || !meta.ref || !meta.sha || !meta.language) {
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) {
253
318
  fail(`Incomplete BigConfig metadata in ${meta.manifest}`);
254
319
  }
255
320
  return meta;
256
321
  }
257
322
 
258
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
+ }
259
327
  const expectedRepo = spec.slug;
260
328
  const problems = [];
261
329
  if (meta.repo !== expectedRepo) problems.push(`repo ${JSON.stringify(meta.repo)} != ${JSON.stringify(expectedRepo)}`);
@@ -266,6 +334,23 @@ function validateExistingMetadata(meta, spec, sha) {
266
334
  }
267
335
  }
268
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
+
269
354
  function quoteToml(s) {
270
355
  return JSON.stringify(String(s));
271
356
  }
@@ -276,6 +361,24 @@ function writeRunFile(text) {
276
361
  if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
277
362
  }
278
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
+
279
382
  function clojureCoord(spec) {
280
383
  return `io.github.${spec.owner}/${spec.repo}`;
281
384
  }
@@ -335,8 +438,133 @@ function writeNativeManifest(spec, sha, target) {
335
438
  fail(`Unsupported language: ${target.language}`);
336
439
  }
337
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
+
338
497
  // --- target dependency setup and execution -------------------------------
339
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
+
340
568
  async function ensureTargetDeps(meta) {
341
569
  if (meta.language === 'typescript') {
342
570
  requireCommand('node', 'Install Node.js and try again.');
@@ -357,6 +585,7 @@ async function ensureTargetDeps(meta) {
357
585
  const code = await runCommand('uv', ['sync']);
358
586
  if (code !== 0) process.exit(code);
359
587
  }
588
+ exposePythonResources(meta);
360
589
  return;
361
590
  }
362
591
  }
@@ -395,9 +624,36 @@ async function initialize(spec, sha) {
395
624
  };
396
625
  }
397
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
+
398
648
  async function restoreRunIfMissing(meta) {
399
649
  const runPath = path.join(process.cwd(), meta.run || 'run');
400
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
+ }
401
657
  const [owner, repo] = meta.repo.split('/');
402
658
  const spec = { owner, repo, slug: meta.repo, ref: meta.ref };
403
659
  const runText = await fetchFile(spec, meta.sha, 'run', { required: true });
@@ -652,11 +908,19 @@ function runBb(bbPath, args, javaHome, extraEnv = {}) {
652
908
 
653
909
  async function main(argv) {
654
910
  let args = [...argv];
655
- let spec = args.length ? parseSpec(args[0]) : null;
656
- if (spec) args = args.slice(1);
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);
657
915
 
658
916
  let meta = readMetadata();
659
- if (spec) {
917
+ if (localSpec) {
918
+ if (meta) {
919
+ validateExistingLocalMetadata(meta, localSpec);
920
+ } else {
921
+ meta = initializeLocal(localSpec);
922
+ }
923
+ } else if (spec) {
660
924
  const sha = await resolveRef(spec);
661
925
  if (meta) {
662
926
  validateExistingMetadata(meta, spec, sha);
@@ -681,10 +945,14 @@ if (require.main === module) {
681
945
 
682
946
  module.exports = {
683
947
  parseSpec,
948
+ isLocalSpec,
949
+ parseLocalSpec,
684
950
  resolveRef,
685
951
  detectTarget,
952
+ detectTargetLocal,
686
953
  readMetadata,
687
954
  validateExistingMetadata,
955
+ validateExistingLocalMetadata,
688
956
  resolvePlatform,
689
957
  cacheRoot,
690
958
  findJavaHome,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-pkg",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Create and run a BigConfig CLI from a language-specific GitHub package.",
5
5
  "bin": {
6
6
  "bc-pkg": "bin/bc-pkg.js"