bc-pkg 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +31 -0
  2. package/bin/bc-pkg.js +216 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,6 +24,37 @@ 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. Python template data
55
+ ships as a top-level `resources` package, which BigConfig's renderer resolves
56
+ through `importlib.resources`, so no `./resources` directory is created.
57
+
27
58
  ## What is created
28
59
 
29
60
  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,6 +438,62 @@ 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
 
340
499
  async function ensureTargetDeps(meta) {
@@ -395,9 +554,36 @@ async function initialize(spec, sha) {
395
554
  };
396
555
  }
397
556
 
557
+ function initializeLocal(local) {
558
+ if (local.path === fs.realpathSync(process.cwd())) {
559
+ fail("local path is the current directory; run bc-pkg from a separate directory so it does not overwrite the package's own manifest.");
560
+ }
561
+ const target = detectTargetLocal(local);
562
+ const runSource = path.join(local.path, 'run');
563
+ if (!fs.existsSync(runSource) || !fs.statSync(runSource).isFile()) fail(`${local.path} has no run`);
564
+ linkRunFile(runSource);
565
+ writeNativeManifestLocal(local, target);
566
+ return {
567
+ repo: null,
568
+ ref: null,
569
+ sha: null,
570
+ language: target.language,
571
+ run: 'run',
572
+ packageName: target.packageName,
573
+ local: true,
574
+ path: local.path,
575
+ };
576
+ }
577
+
398
578
  async function restoreRunIfMissing(meta) {
399
579
  const runPath = path.join(process.cwd(), meta.run || 'run');
400
580
  if (fs.existsSync(runPath)) return;
581
+ if (meta.local) {
582
+ const source = path.join(meta.path, meta.run || 'run');
583
+ if (!fs.existsSync(source)) fail(`${meta.path} has no ${meta.run || 'run'}`);
584
+ linkRunFile(source);
585
+ return;
586
+ }
401
587
  const [owner, repo] = meta.repo.split('/');
402
588
  const spec = { owner, repo, slug: meta.repo, ref: meta.ref };
403
589
  const runText = await fetchFile(spec, meta.sha, 'run', { required: true });
@@ -652,11 +838,19 @@ function runBb(bbPath, args, javaHome, extraEnv = {}) {
652
838
 
653
839
  async function main(argv) {
654
840
  let args = [...argv];
655
- let spec = args.length ? parseSpec(args[0]) : null;
656
- if (spec) args = args.slice(1);
841
+ const first = args.length ? args[0] : null;
842
+ const localSpec = first != null && isLocalSpec(first) ? parseLocalSpec(first) : null;
843
+ const spec = localSpec ? null : first ? parseSpec(first) : null;
844
+ if (localSpec || spec) args = args.slice(1);
657
845
 
658
846
  let meta = readMetadata();
659
- if (spec) {
847
+ if (localSpec) {
848
+ if (meta) {
849
+ validateExistingLocalMetadata(meta, localSpec);
850
+ } else {
851
+ meta = initializeLocal(localSpec);
852
+ }
853
+ } else if (spec) {
660
854
  const sha = await resolveRef(spec);
661
855
  if (meta) {
662
856
  validateExistingMetadata(meta, spec, sha);
@@ -681,10 +875,14 @@ if (require.main === module) {
681
875
 
682
876
  module.exports = {
683
877
  parseSpec,
878
+ isLocalSpec,
879
+ parseLocalSpec,
684
880
  resolveRef,
685
881
  detectTarget,
882
+ detectTargetLocal,
686
883
  readMetadata,
687
884
  validateExistingMetadata,
885
+ validateExistingLocalMetadata,
688
886
  resolvePlatform,
689
887
  cacheRoot,
690
888
  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.3",
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"