@theholocron/cli 2.0.0-alpha.76 → 2.0.0-alpha.77

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 (2) hide show
  1. package/dist/cli.mjs +271 -2
  2. package/package.json +2 -3
package/dist/cli.mjs CHANGED
@@ -10,9 +10,9 @@ import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1 } f
10
10
  import { Entry, findCredentials } from "@napi-rs/keyring";
11
11
  import ora from "ora";
12
12
  import chalk from "chalk";
13
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
13
14
  import { createHash } from "node:crypto";
14
15
  import { createGitHubClient } from "@theholocron/github-client";
15
- import { execFile, execFileSync, spawnSync } from "node:child_process";
16
16
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import { promisify } from "node:util";
@@ -433,6 +433,210 @@ async function tryLoadHint(importer, packageName) {
433
433
  }
434
434
  }
435
435
  //#endregion
436
+ //#region src/commands/new.ts
437
+ /**
438
+ * `holocron new <type> <name>` — create a GitHub repo from a template and
439
+ * bootstrap it by replacing all template-slug casing variants with the new
440
+ * project name.
441
+ *
442
+ * Flow:
443
+ * 1. Preflight — verify `gh` CLI is available.
444
+ * 2. Resolve type, name, description (prompt via readline if missing).
445
+ * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
446
+ * → clones to `<cwd>/<name>/`
447
+ * 4. Detect template slug from cloned package.json.
448
+ * 5. Replace all casing variants of the slug across every text file.
449
+ * 6. Replace `<description>` placeholder if a description was given.
450
+ * 7. Commit the patched files (-s for DCO).
451
+ * 8. Unless --no-verify: `pnpm install` in the new repo.
452
+ * 9. Print next steps.
453
+ */
454
+ var NewError = class extends Error {
455
+ name = "NewError";
456
+ };
457
+ function cap(word) {
458
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
459
+ }
460
+ /**
461
+ * Derive all common casing variants of a kebab-case slug (e.g.
462
+ * "cli-template") and map each to the corresponding form of a
463
+ * kebab-case name (e.g. "my-tool").
464
+ *
465
+ * Returns search→replacement pairs, deduplicating single-word slugs
466
+ * that would otherwise produce identical entries.
467
+ */
468
+ function deriveVariants(slug, name) {
469
+ const sw = slug.split("-");
470
+ const nw = name.split("-");
471
+ const pairs = [
472
+ [slug, name],
473
+ [sw.join("_"), nw.join("_")],
474
+ [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
475
+ [sw.map(cap).join(""), nw.map(cap).join("")],
476
+ [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
477
+ [sw.map(cap).join(" "), nw.map(cap).join(" ")]
478
+ ];
479
+ const seen = /* @__PURE__ */ new Set();
480
+ return pairs.filter(([s]) => {
481
+ if (seen.has(s)) return false;
482
+ seen.add(s);
483
+ return true;
484
+ });
485
+ }
486
+ const SKIP_DIRS$1 = /* @__PURE__ */ new Set([
487
+ ".git",
488
+ "node_modules",
489
+ "dist",
490
+ ".turbo"
491
+ ]);
492
+ function defaultWalkFiles$1(dir) {
493
+ const results = [];
494
+ for (const entry of readdirSync(dir)) {
495
+ if (SKIP_DIRS$1.has(entry)) continue;
496
+ const full = path.join(dir, entry);
497
+ const stat = statSync(full);
498
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$1(full));
499
+ else if (stat.isFile()) results.push(full);
500
+ }
501
+ return results;
502
+ }
503
+ function isBinary(content) {
504
+ for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
505
+ return false;
506
+ }
507
+ function patchFiles(dir, variants, description, print, readFn, writeFn, walkFn) {
508
+ const patched = [];
509
+ for (const filepath of walkFn(dir)) {
510
+ let content;
511
+ try {
512
+ content = readFn(filepath);
513
+ } catch {
514
+ continue;
515
+ }
516
+ if (isBinary(content)) continue;
517
+ const original = content;
518
+ for (const [search, replacement] of variants) content = content.split(search).join(replacement);
519
+ if (description !== void 0) content = content.split("<description>").join(description);
520
+ if (content !== original) {
521
+ writeFn(filepath, content);
522
+ print(` ✓ ${path.relative(dir, filepath)}`);
523
+ patched.push(filepath);
524
+ }
525
+ }
526
+ return patched;
527
+ }
528
+ function preflight$1() {
529
+ const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
530
+ if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
531
+ }
532
+ function defaultExec$3(cmd, args, opts) {
533
+ execFileSync(cmd, args, {
534
+ cwd: opts.cwd,
535
+ stdio: opts.stdio
536
+ });
537
+ }
538
+ function defaultReadFile(filepath) {
539
+ return readFileSync(filepath, "utf-8");
540
+ }
541
+ function defaultWriteFile(filepath, content) {
542
+ mkdirSync(path.dirname(filepath), { recursive: true });
543
+ writeFileSync(filepath, content, "utf-8");
544
+ }
545
+ async function runNew(input) {
546
+ const cwd = input.cwd ?? process.cwd();
547
+ const org = input.org ?? "theholocron";
548
+ const print = input.print ?? ((line) => console.log(line));
549
+ const execFn = input.exec ?? defaultExec$3;
550
+ const readFn = input.readFile ?? defaultReadFile;
551
+ const writeFn = input.writeFile ?? defaultWriteFile;
552
+ const walkFn = input.walkFiles ?? defaultWalkFiles$1;
553
+ preflight$1();
554
+ const templateRepo = `${org}/${input.type}-template`;
555
+ const newRepo = `${org}/${input.name}`;
556
+ const repoDir = path.join(cwd, input.name);
557
+ if (input.dryRun) {
558
+ print(` Would create ${newRepo} from template ${templateRepo}`);
559
+ print(` Would clone to ${repoDir}`);
560
+ print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
561
+ if (input.description) print(` Would replace <description> → "${input.description}"`);
562
+ return { status: "dry-run" };
563
+ }
564
+ if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
565
+ print(` Creating ${newRepo} from template ${templateRepo}…`);
566
+ try {
567
+ execFn("gh", [
568
+ "repo",
569
+ "create",
570
+ newRepo,
571
+ `--template=${templateRepo}`,
572
+ "--private",
573
+ "--clone"
574
+ ], {
575
+ cwd,
576
+ stdio: "inherit"
577
+ });
578
+ } catch (err) {
579
+ throw new NewError(`gh repo create failed: ${err instanceof Error ? err.message : String(err)}`);
580
+ }
581
+ let templateSlug = `${input.type}-template`;
582
+ const pkgJsonPath = path.join(repoDir, "package.json");
583
+ if (existsSync(pkgJsonPath)) try {
584
+ const pkg = JSON.parse(readFn(pkgJsonPath));
585
+ if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").pop() ?? templateSlug;
586
+ } catch {}
587
+ print(` Detected template slug: ${templateSlug}`);
588
+ print(` Patching files…`);
589
+ const filesPatched = patchFiles(repoDir, deriveVariants(templateSlug, input.name), input.description, print, readFn, writeFn, walkFn);
590
+ print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
591
+ if (filesPatched.length > 0) {
592
+ execFn("git", ["add", "-A"], {
593
+ cwd: repoDir,
594
+ stdio: "inherit"
595
+ });
596
+ execFn("git", [
597
+ "commit",
598
+ "-s",
599
+ "-m",
600
+ `chore: bootstrap from ${templateSlug}`
601
+ ], {
602
+ cwd: repoDir,
603
+ stdio: "inherit"
604
+ });
605
+ }
606
+ if (!input.noVerify) {
607
+ print("");
608
+ print(" Installing dependencies…");
609
+ try {
610
+ execFn("pnpm", ["install"], {
611
+ cwd: repoDir,
612
+ stdio: "inherit"
613
+ });
614
+ } catch (err) {
615
+ print(` ✗ pnpm install failed — ${err instanceof Error ? err.message : String(err)}`);
616
+ return {
617
+ status: "fail",
618
+ repoDir,
619
+ filesPatched,
620
+ message: "pnpm install failed; inspect output above"
621
+ };
622
+ }
623
+ }
624
+ print("");
625
+ print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
626
+ print("");
627
+ print(" Next:");
628
+ print(` 1. cd ${repoDir}`);
629
+ if (input.noVerify) print(` 2. pnpm install`);
630
+ const step = input.noVerify ? 3 : 2;
631
+ print(` ${step}. holocron setup # wire up secrets, teams, labels, etc.`);
632
+ print(` ${step + 1}. git push -u origin HEAD`);
633
+ return {
634
+ status: "ok",
635
+ repoDir,
636
+ filesPatched
637
+ };
638
+ }
639
+ //#endregion
436
640
  //#region src/loader.ts
437
641
  var LoaderError = class extends Error {
438
642
  name = "LoaderError";
@@ -1438,7 +1642,6 @@ async function runNpmPublishInitial(input = {}) {
1438
1642
  const publishArgs = [
1439
1643
  "-r",
1440
1644
  "--filter=./packages/*",
1441
- "--filter=!@theholocron/cli-utils",
1442
1645
  "publish",
1443
1646
  "--access",
1444
1647
  "public",
@@ -4488,6 +4691,72 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
4488
4691
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
4489
4692
  const loaded = await loadConfig(argv.cwd);
4490
4693
  console.log(JSON.stringify(loaded.resolved, null, 2));
4694
+ }).command("new [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
4695
+ type: "string",
4696
+ describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
4697
+ }).positional("name", {
4698
+ type: "string",
4699
+ describe: "New repo name (kebab-case, e.g. my-tool)"
4700
+ }).option("description", {
4701
+ type: "string",
4702
+ describe: "Short description — replaces <description> placeholders in the template"
4703
+ }).option("org", {
4704
+ type: "string",
4705
+ default: "theholocron",
4706
+ describe: "GitHub org that owns the template and will own the new repo"
4707
+ }).option("verify", {
4708
+ type: "boolean",
4709
+ default: true,
4710
+ describe: "Run pnpm install after bootstrapping (default true; --no-verify skips)"
4711
+ }), async (argv) => {
4712
+ try {
4713
+ let type = argv.type;
4714
+ let name = argv.name;
4715
+ let description = argv.description;
4716
+ if (!type || !name || description === void 0) {
4717
+ const rl = createInterface({
4718
+ input: stdin,
4719
+ output: stdout
4720
+ });
4721
+ const ask = (question) => new Promise((resolve) => rl.question(` ${question} `, (answer) => resolve(answer.trim())));
4722
+ try {
4723
+ if (!type) {
4724
+ console.log(" Known types: base, cli, monorepo, nextjs, node, react");
4725
+ type = await ask("Template type:");
4726
+ }
4727
+ if (!name) name = await ask("Repo name (kebab-case):");
4728
+ if (description === void 0) description = await ask("Short description (Enter to skip):");
4729
+ } finally {
4730
+ rl.close();
4731
+ }
4732
+ }
4733
+ if (!type) {
4734
+ console.error("new: template type is required");
4735
+ process.exitCode = 1;
4736
+ return;
4737
+ }
4738
+ if (!name) {
4739
+ console.error("new: repo name is required");
4740
+ process.exitCode = 1;
4741
+ return;
4742
+ }
4743
+ if ((await runNew({
4744
+ type,
4745
+ name,
4746
+ ...description ? { description } : {},
4747
+ org: argv.org,
4748
+ dryRun: argv.dryRun,
4749
+ noVerify: !argv.verify,
4750
+ cwd: argv.cwd
4751
+ })).status === "fail") process.exitCode = 1;
4752
+ } catch (err) {
4753
+ if (err instanceof NewError) {
4754
+ console.error(`new: ${err.message}`);
4755
+ process.exitCode = 1;
4756
+ return;
4757
+ }
4758
+ throw err;
4759
+ }
4491
4760
  }).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
4492
4761
  type: "string",
4493
4762
  demandOption: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/cli",
3
- "version": "2.0.0-alpha.76",
3
+ "version": "2.0.0-alpha.77",
4
4
  "description": "The Holocron CLI — a pluggable, capability-based orchestrator for spinning up and operating software projects.",
5
5
  "homepage": "https://github.com/theholocron/holocron/tree/main/packages/cli#readme",
6
6
  "bugs": "https://github.com/theholocron/holocron/issues",
@@ -54,8 +54,7 @@
54
54
  "globals": "^17.7.0",
55
55
  "tsdown": "^0.22.3",
56
56
  "typescript": "^5.9.3",
57
- "vitest": "^4.1.10",
58
- "@theholocron/cli-utils": "0.0.0"
57
+ "vitest": "^4.1.10"
59
58
  },
60
59
  "publishConfig": {
61
60
  "access": "public"