@theholocron/cli 2.1.1 → 2.2.0

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 +106 -3
  2. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
- import path, { basename, dirname, join, relative } from "node:path";
4
+ import path, { basename, dirname, join, relative, resolve } from "node:path";
5
5
  import yargs from "yargs";
6
6
  import { hideBin } from "yargs/helpers";
7
7
  import { createInterface } from "node:readline";
@@ -10,13 +10,13 @@ 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 { homedir } from "node:os";
13
14
  import { execFile, execFileSync, spawnSync } from "node:child_process";
14
15
  import { createHash } from "node:crypto";
15
16
  import { createGitHubClient } from "@theholocron/github-client";
16
17
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
17
18
  import { pathToFileURL } from "node:url";
18
19
  import { promisify } from "node:util";
19
- import { homedir } from "node:os";
20
20
  //#region src/capabilities/index.ts
21
21
  const CARDINALITY = {
22
22
  source: "single",
@@ -433,6 +433,87 @@ async function tryLoadHint(importer, packageName) {
433
433
  }
434
434
  }
435
435
  //#endregion
436
+ //#region src/commands/clone.ts
437
+ async function listOrgRepos(org, token, fetchFn) {
438
+ const repos = [];
439
+ let url = `https://api.github.com/orgs/${org}/repos?per_page=100&type=all`;
440
+ while (url) {
441
+ const res = await fetchFn(url, { headers: {
442
+ Authorization: `Bearer ${token}`,
443
+ Accept: "application/vnd.github+json",
444
+ "X-GitHub-Api-Version": "2022-11-28"
445
+ } });
446
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText} — check that the token has org:read scope`);
447
+ repos.push(...await res.json());
448
+ const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
449
+ url = next ? next[1] : null;
450
+ }
451
+ return repos;
452
+ }
453
+ async function runClone(input) {
454
+ const print = input.print ?? ((line) => console.log(line));
455
+ const fetchFn = input.fetch ?? globalThis.fetch;
456
+ const dryRun = input.dryRun ?? false;
457
+ const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
458
+ const exec = input.exec ?? ((cmd, args, opts) => {
459
+ return { status: spawnSync(cmd, args, {
460
+ cwd: opts.cwd,
461
+ stdio: "inherit"
462
+ }).status };
463
+ });
464
+ print(style.header(`Holocron clone — org=${input.org} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
465
+ if (!existsSync(targetDir)) if (dryRun) print(style.dim(` would create ${targetDir}`));
466
+ else mkdirSync(targetDir, { recursive: true });
467
+ let repos;
468
+ try {
469
+ repos = await listOrgRepos(input.org, input.token, fetchFn);
470
+ } catch (err) {
471
+ return {
472
+ status: "fail",
473
+ cloned: 0,
474
+ skipped: 0,
475
+ failed: 0,
476
+ message: err instanceof Error ? err.message : String(err)
477
+ };
478
+ }
479
+ let cloned = 0;
480
+ let skipped = 0;
481
+ let failed = 0;
482
+ for (const repo of repos) {
483
+ const dest = join(targetDir, repo.name.startsWith(".") ? repo.name.slice(1) : repo.name);
484
+ if (existsSync(dest)) {
485
+ print(style.dim(` skip ${repo.full_name}`));
486
+ skipped++;
487
+ continue;
488
+ }
489
+ if (dryRun) {
490
+ print(style.dim(` would clone ${repo.full_name} → ${dest}`));
491
+ cloned++;
492
+ continue;
493
+ }
494
+ print(style.step(` clone ${repo.full_name}`));
495
+ if (exec("git", [
496
+ "clone",
497
+ repo.ssh_url,
498
+ dest
499
+ ], { cwd: targetDir }).status !== 0) {
500
+ print(style.fail(` failed ${repo.full_name}`));
501
+ failed++;
502
+ } else {
503
+ print(style.success(` cloned ${repo.name}`));
504
+ cloned++;
505
+ }
506
+ }
507
+ const summary = `${cloned} cloned, ${skipped} skipped, ${failed} failed`;
508
+ print(dryRun ? style.dim(`\n dry-run: ${summary}`) : failed > 0 ? style.fail(`\n ${summary}`) : style.success(`\n ${summary}`));
509
+ return {
510
+ status: dryRun ? "dry-run" : failed > 0 ? "fail" : "ok",
511
+ cloned,
512
+ skipped,
513
+ failed
514
+ };
515
+ }
516
+ //#endregion
436
517
  //#region src/commands/new.ts
437
518
  /**
438
519
  * `holocron new <type> <name>` — create a GitHub repo from a template and
@@ -1100,7 +1181,7 @@ var install_default = "name: Install dependencies\ndescription: Install project
1100
1181
  var setup_node_default = "name: Setup Node\ndescription: Install pnpm and Node.js with pnpm dependency caching.\n\ninputs:\n node-version:\n description: Node.js version\n required: false\n default: \"22.x\"\n\nruns:\n using: composite\n\n steps:\n - name: Setup pnpm\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4\n\n - name: Setup Node.js\n uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4\n with:\n node-version: ${{ hashFiles('.node-version') != '' && '' || inputs.node-version }}\n node-version-file: ${{ hashFiles('.node-version') != '' && '.node-version' || '' }}\n cache: ${{ hashFiles('pnpm-lock.yaml') != '' && 'pnpm' || '' }}\n\n - name: Add node_modules/.bin to PATH\n shell: bash\n run: echo \"$GITHUB_WORKSPACE/node_modules/.bin\" >> $GITHUB_PATH\n";
1101
1182
  //#endregion
1102
1183
  //#region src/templates/workflows/audit.yml
1103
- var audit_default$1 = "name: Audit\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n build-script:\n description: Script to build and upload bundle stats to Codecov\n type: string\n required: false\n default: pnpm build\n secrets:\n CODECOV_TOKEN:\n required: false\n\njobs:\n bundle-size:\n name: Audit the bundle size\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 15\n concurrency:\n group: audit-${{ github.ref }}\n cancel-in-progress: true\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$BUILD_SCRIPT\"\n name: Build and upload bundle stats\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n";
1184
+ var audit_default$1 = "name: Audit\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n build-script:\n description: Script to build and upload bundle stats to Codecov\n type: string\n required: false\n default: pnpm build\n run-knip:\n description: Run Knip to detect unused files, exports, and dependencies\n type: boolean\n required: false\n default: false\n knip-script:\n description: Script that invokes Knip (must exit non-zero on findings)\n type: string\n required: false\n default: pnpm run audit\n secrets:\n CODECOV_TOKEN:\n required: false\n\njobs:\n bundle-size:\n name: Audit the bundle size\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 15\n concurrency:\n group: audit-${{ github.ref }}\n cancel-in-progress: true\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$BUILD_SCRIPT\"\n name: Build and upload bundle stats\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n\n knip:\n name: Knip\n if: ${{ inputs.run-knip }}\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n persist-credentials: false\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$KNIP_SCRIPT\"\n name: Run Knip\n env:\n KNIP_SCRIPT: ${{ inputs.knip-script }}\n";
1104
1185
  //#endregion
1105
1186
  //#region src/templates/workflows/bookkeeping.yml
1106
1187
  var bookkeeping_default$1 = "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n configuration-path:\n description: Path to the labeler configuration file in the calling repo\n type: string\n required: false\n default: .github/labeler.yml\n\njobs:\n label:\n name: Apply Labels\n permissions:\n contents: read\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n concurrency:\n group: bookkeeping-${{ github.event.pull_request.number || github.event.issue.number }}\n cancel-in-progress: true\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n with:\n sparse-checkout: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n sparse-checkout-cone-mode: false\n\n - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4\n if: ${{ github.event_name == 'pull_request' && hashFiles(inputs.configuration-path || '.github/labeler.yml') != '' }}\n # v3.4 bundles Node 20; allow it to run under Actions' current default.\n env:\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true\n with:\n # Fall back to default path when triggered directly (not via workflow_call)\n # because inputs.* defaults only apply on workflow_call events.\n configuration-path: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n include-title: 1\n include-body: 0\n sync-labels: 1\n enable-versioned-regex: 0\n repo-token: ${{ github.token }}\n";
@@ -4513,6 +4594,28 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
4513
4594
  describe: "Directory to search for holocron.config.json"
4514
4595
  }).command("version", "Print the CLI version", () => {}, () => {
4515
4596
  console.log(`holocron ${CLI_VERSION}`);
4597
+ }).command("clone", "Clone all repos in a GitHub org as siblings under a single directory", (y) => y.option("org", {
4598
+ type: "string",
4599
+ demandOption: true,
4600
+ describe: "GitHub org to clone (e.g., theholocron)"
4601
+ }).option("dir", {
4602
+ type: "string",
4603
+ describe: "Parent directory to clone into (default: ~/Code/<org>)"
4604
+ }), async (argv) => {
4605
+ const tokens = tokenContext(argv.token);
4606
+ if (!tokens) return;
4607
+ const token = tokens.cliTokens?.["github"] ?? tokens.cliToken ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
4608
+ if (!token) {
4609
+ console.error("clone: GitHub token required — pass --token or set GITHUB_TOKEN");
4610
+ process.exitCode = 1;
4611
+ return;
4612
+ }
4613
+ if ((await runClone({
4614
+ org: argv.org,
4615
+ token,
4616
+ dryRun: argv.dryRun,
4617
+ ...argv.dir ? { dir: argv.dir } : {}
4618
+ })).status === "fail") process.exitCode = 1;
4516
4619
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
4517
4620
  type: "string",
4518
4621
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/cli",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
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",