@theholocron/cli 4.15.0 → 4.16.1

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.
package/README.md CHANGED
@@ -99,6 +99,16 @@ export default defineConfig({
99
99
  });
100
100
  ```
101
101
 
102
+ The generated ruleset also sets `require_extra_approval_for_unattributed_changes:
103
+ false` — with `required_approving_review_count: 0` and a single maintainer, the
104
+ GitHub default (`true`) is an unsatisfiable block on any PR with an
105
+ unverified/unattributed commit. Repository-admin bypass still covers the real
106
+ risk. And the `codecov.yml` `holocron setup` writes carries `if_not_found:
107
+ success` on every status default, so an infra-only PR (dep bump, config,
108
+ lockfile — no coverable change) passes instead of hanging on a `codecov/*`
109
+ required check that never posts. A re-run of `holocron setup` backfills both into
110
+ an existing repo.
111
+
102
112
  ### Git hooks
103
113
 
104
114
  ```ts
@@ -275,6 +285,20 @@ export default acmeConfig;
275
285
 
276
286
  ```
277
287
 
288
+ ## Upgrading
289
+
290
+ - **`holocron upgrade node <to>`** — patches every Node.js version pin
291
+ (`engines.node`, `.nvmrc`, `.node-version`, `.github/workflows/*`,
292
+ `Dockerfile`, `.tool-versions`) to a new major.
293
+ - **`holocron upgrade deps`** — bumps every `@theholocron/*` pin in
294
+ `pnpm-workspace.yaml` (`catalog:` and every named `catalogs.*`) to its latest
295
+ published version, then migrates `holocron.config.ts` from the 7.x preset
296
+ shape (`const { repo, workflows } = node()` + `repo.requiredChecks` +
297
+ `workflows:`) to the current composed-preset API (`const preset = node()` +
298
+ `...preset` + `tasks:` + `extraRequiredChecks:`). Mechanical transforms only —
299
+ review the result, then `pnpm install && holocron ci`. `--pins-only` skips
300
+ the config migration; `--dry-run` prints without writing.
301
+
278
302
  ## Auth — fine-grained tokens
279
303
 
280
304
  Each GitHub capability resolves its own fine-grained PAT so a compromised
@@ -385,7 +409,7 @@ holds the orchestration + the Holocron-specific credential resolution
385
409
  never hand-edit its `.github/workflows/*`).
386
410
  - `src/commands/` — `setup`, `sync`, `doctor`, `deploy`, `secret set`,
387
411
  `secrets sync`, `npm publish-initial`, `sync-github`, `upgrade node`,
388
- `plugin create`, `auth`
412
+ `upgrade deps`, `plugin create`, `auth`
389
413
 
390
414
  ## Status
391
415
 
package/dist/cli.mjs CHANGED
@@ -3379,7 +3379,7 @@ function createIgnoreConfig() {
3379
3379
  }
3380
3380
  //#endregion
3381
3381
  //#region src/templates/configs/codecov/codecov.yml
3382
- var codecov_default = "codecov:\n require_ci_to_pass: true\n\ncoverage:\n precision: 2\n round: down\n status:\n project:\n default:\n target: auto\n threshold: 2%\n patch:\n default:\n target: 80%\n\ncomment:\n layout: \"reach,diff,flags,components\"\n behavior: default\n require_changes: true\n\ncomponent_management:\n default_rules:\n statuses:\n - type: patch\n target: 80%\n individual_components:\n";
3382
+ var codecov_default = "codecov:\n require_ci_to_pass: true\n\ncoverage:\n precision: 2\n round: down\n status:\n project:\n default:\n target: auto\n threshold: 2%\n # Infra-only PRs (dep bumps, config, lockfile) touch no coverable code,\n # so codecov produces no report to compare. Pass instead of blocking\n # forever on a required check that will never post.\n if_not_found: success\n patch:\n default:\n target: 80%\n if_not_found: success\n\ncomment:\n layout: \"reach,diff,flags,components\"\n behavior: default\n require_changes: true\n\ncomponent_management:\n default_rules:\n statuses:\n - type: patch\n target: 80%\n if_not_found: success\n individual_components:\n";
3383
3383
  //#endregion
3384
3384
  //#region src/templates/configs/codecov/utils.ts
3385
3385
  const INDIVIDUAL_COMPONENTS_MARKER = " individual_components:";
@@ -3393,10 +3393,45 @@ function codecovComponentBlock(packages) {
3393
3393
  ``
3394
3394
  ]).join("\n");
3395
3395
  }
3396
+ /**
3397
+ * Ensure every codecov status default carries `if_not_found: success`, so an
3398
+ * infra-only PR (dep bump, config, lockfile — no coverable change) passes
3399
+ * instead of hanging forever on a required check codecov never posts.
3400
+ *
3401
+ * Injects the key on the line after each `target:` that sits inside a status
3402
+ * block (`coverage.status.*.default` or a `statuses:` list item — indent ≥ 8,
3403
+ * or ≥ 8 for a `- type:` list entry), when the immediate sibling lines do not
3404
+ * already declare it.
3405
+ */
3406
+ function ensureIfNotFound(yaml) {
3407
+ const lines = yaml.split("\n");
3408
+ const out = [];
3409
+ for (let i = 0; i < lines.length; i++) {
3410
+ const line = lines[i];
3411
+ out.push(line);
3412
+ const m = /^(\s{8,})target:\s+\S+\s*$/.exec(line);
3413
+ if (!m) continue;
3414
+ const indent = m[1];
3415
+ let alreadySet = false;
3416
+ for (let j = i + 1; j < lines.length; j++) {
3417
+ const sib = lines[j];
3418
+ if (sib.trim() === "") continue;
3419
+ const sibIndent = sib.match(/^\s*/)[0].length;
3420
+ if (sibIndent < indent.length) break;
3421
+ if (sibIndent === indent.length && /^\s*if_not_found\s*:/.test(sib)) {
3422
+ alreadySet = true;
3423
+ break;
3424
+ }
3425
+ if (sibIndent === indent.length && /^\s*(- |\w)/.test(sib) && !/^\s*(threshold|flags|paths|branches)\s*:/.test(sib)) break;
3426
+ }
3427
+ if (!alreadySet) out.push(`${indent}if_not_found: success`);
3428
+ }
3429
+ return out.join("\n");
3430
+ }
3396
3431
  function mergeCodecovComponents(existing, packages) {
3397
3432
  const idx = existing.indexOf(INDIVIDUAL_COMPONENTS_MARKER);
3398
- if (idx === -1) return existing;
3399
- return existing.slice(0, idx + 24) + codecovComponentBlock(packages);
3433
+ if (idx === -1) return ensureIfNotFound(existing);
3434
+ return ensureIfNotFound(existing.slice(0, idx + 24)) + codecovComponentBlock(packages);
3400
3435
  }
3401
3436
  async function readWorkspacePackages(repoRoot) {
3402
3437
  const packagesDir = join(repoRoot, "packages");
@@ -3965,7 +4000,8 @@ function buildRulesetPayload(requiredChecks = []) {
3965
4000
  dismiss_stale_reviews_on_push: false,
3966
4001
  require_code_owner_review: false,
3967
4002
  require_last_push_approval: false,
3968
- required_review_thread_resolution: true
4003
+ required_review_thread_resolution: true,
4004
+ require_extra_approval_for_unattributed_changes: false
3969
4005
  }
3970
4006
  }
3971
4007
  ];
@@ -6234,6 +6270,224 @@ async function runSyncGithub(input) {
6234
6270
  });
6235
6271
  }
6236
6272
  //#endregion
6273
+ //#region src/commands/upgrade-deps.ts
6274
+ /**
6275
+ * `holocron upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
6276
+ * its `holocron.config.ts` up to the current major.
6277
+ *
6278
+ * Two concerns:
6279
+ *
6280
+ * 1. **Catalog pins.** Every `@theholocron/*` entry in `pnpm-workspace.yaml`
6281
+ * (`catalog:` and every named `catalogs.*`) is bumped to the latest published
6282
+ * version, preserving the range prefix (`^` / `~`). Never downgrades.
6283
+ * 2. **`holocron.config.ts` migration.** `@theholocron/holocron-config@8`
6284
+ * dropped the 7.x preset shape (`const { repo, workflows, providers } =
6285
+ * node()` + `repo.requiredChecks` + `workflows:`) for a composed-preset
6286
+ * (`const preset = node()` + `...preset` + `tasks:` + `extraRequiredChecks:`).
6287
+ * This applies the mechanical transforms and flags anything that needs a
6288
+ * human eye.
6289
+ *
6290
+ * After running, `pnpm install && holocron ci`.
6291
+ */
6292
+ const FETCH_TIMEOUT_MS$1 = 4e3;
6293
+ /** Resolve a package's `latest` dist-tag from the npm registry. */
6294
+ const fetchLatestFromNpm = async (pkg) => {
6295
+ try {
6296
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, {
6297
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS$1),
6298
+ headers: { accept: "application/vnd.npm.install-v1+json" }
6299
+ });
6300
+ if (!res.ok) return null;
6301
+ return (await res.json())["dist-tags"]?.["latest"] ?? null;
6302
+ } catch {
6303
+ return null;
6304
+ }
6305
+ };
6306
+ /** `a > b` for `x.y.z` release versions (prerelease tags are ignored). */
6307
+ function gt(a, b) {
6308
+ const pa = a.split(".").map((n) => parseInt(n, 10));
6309
+ const pb = b.split(".").map((n) => parseInt(n, 10));
6310
+ for (let i = 0; i < 3; i++) {
6311
+ const x = pa[i] ?? 0;
6312
+ const y = pb[i] ?? 0;
6313
+ if (x !== y) return x > y;
6314
+ }
6315
+ return false;
6316
+ }
6317
+ const CATALOG_LINE = /^(\s*['"]?)(@theholocron\/[a-z0-9-]+)(['"]?\s*:\s*['"]?)([~^]?)(\d+\.\d+\.\d+[\w.-]*)(['"]?\s*)$/;
6318
+ /**
6319
+ * Rewrite every `@theholocron/*` pin in a `pnpm-workspace.yaml` to `latest`,
6320
+ * keeping the range prefix. `latest` is looked up once per package.
6321
+ */
6322
+ async function bumpCatalogs(content, fetchLatest) {
6323
+ const lines = content.split("\n");
6324
+ const wanted = /* @__PURE__ */ new Set();
6325
+ for (const line of lines) {
6326
+ const m = CATALOG_LINE.exec(line);
6327
+ if (m) wanted.add(m[2]);
6328
+ }
6329
+ const latest = /* @__PURE__ */ new Map();
6330
+ await Promise.all([...wanted].map(async (pkg) => {
6331
+ latest.set(pkg, await fetchLatest(pkg));
6332
+ }));
6333
+ const bumps = [];
6334
+ return {
6335
+ content: lines.map((line) => {
6336
+ const m = CATALOG_LINE.exec(line);
6337
+ if (!m) return line;
6338
+ const [, pre, pkg, mid, prefix, current, post] = m;
6339
+ const next = latest.get(pkg);
6340
+ if (!next || next === current || !gt(next, current)) return line;
6341
+ bumps.push({
6342
+ pkg,
6343
+ from: current,
6344
+ to: next
6345
+ });
6346
+ return `${pre}${pkg}${mid}${prefix}${next}${post}`;
6347
+ }).join("\n"),
6348
+ bumps
6349
+ };
6350
+ }
6351
+ /** True if the file still uses the 7.x preset shape. */
6352
+ function isLegacyConfig(content) {
6353
+ return /const\s*\{[^}]*\b(repo|workflows|providers)\b[^}]*\}\s*=\s*(compose\(|[a-zA-Z]+\(\))/.test(content) || /\.\.\.repo\.requiredChecks/.test(content) || /^\s*workflows\s*:/m.test(content);
6354
+ }
6355
+ /**
6356
+ * Apply the mechanical 7.x → 8.x transforms to a `holocron.config.ts`. Best
6357
+ * effort — the result should always be reviewed and run through `holocron ci`.
6358
+ */
6359
+ function migrateConfig(content) {
6360
+ const transforms = [];
6361
+ const warnings = [];
6362
+ let s = content;
6363
+ if (!isLegacyConfig(s)) return {
6364
+ content,
6365
+ changed: false,
6366
+ transforms,
6367
+ warnings
6368
+ };
6369
+ s = s.replace(/const\s*\{[^}]*\}\s*=\s*((?:compose\([\s\S]*?\)|[a-zA-Z]+\([\s\S]*?\)))\s*;/, (_m, expr) => {
6370
+ transforms.push("destructured preset → `const preset = …`");
6371
+ return `const preset = ${expr.trim()};`;
6372
+ });
6373
+ if (/const preset =/.test(s) && !/\.\.\.preset\b/.test(s)) {
6374
+ s = s.replace(/(defineConfig\(\{\s*\n)/, `$1\t...preset,\n`);
6375
+ transforms.push("spread `...preset` into defineConfig()");
6376
+ }
6377
+ s = s.replace(/^\torg,\n/m, "");
6378
+ s = s.replace(/^\tdomain,\n/m, "");
6379
+ s = s.replace(/^\tdocs,\n/m, "");
6380
+ if (/\.\.\.repo\.properties/.test(s)) {
6381
+ s = s.replace(/\.\.\.repo\.properties/g, "...preset.repo?.properties");
6382
+ transforms.push("`...repo.properties` → `...preset.repo?.properties`");
6383
+ }
6384
+ if (/([{[,]\s*)\.\.\.repo\b(?!\.)/.test(s)) {
6385
+ s = s.replace(/([{[,]\s*)\.\.\.repo\b(?!\.)/g, "$1...preset.repo");
6386
+ transforms.push("`...repo` → `...preset.repo`");
6387
+ }
6388
+ if (/\.\.\.providers\b/.test(s)) {
6389
+ s = s.replace(/\.\.\.providers\b/g, "...preset.providers");
6390
+ transforms.push("`...providers` → `...preset.providers`");
6391
+ }
6392
+ const rc = /(\n\s*)requiredChecks\s*:\s*\[\s*\n?\s*\.\.\.repo\.requiredChecks\s*,?\s*([\s\S]*?)\n\s*\]\s*,/.exec(s);
6393
+ if (rc) {
6394
+ const custom = rc[2].trim().replace(/,\s*$/, "");
6395
+ s = s.replace(rc[0], "");
6396
+ if (custom) {
6397
+ const block = `\textraRequiredChecks: [\n\t\t${custom.split("\n").map((l) => l.trim()).filter(Boolean).join("\n ")}\n\t],\n`;
6398
+ s = s.replace(/(defineConfig\(\{\s*\n(?:\t\.\.\.preset,\n)?)/, `$1${block}`);
6399
+ transforms.push("`repo.requiredChecks` custom entries → top-level `extraRequiredChecks`");
6400
+ warnings.push("check the moved `extraRequiredChecks` — the preset already contributes lint/test/typecheck");
6401
+ } else {
6402
+ s = s.replace(/,(\s*)\.\.\.repo\.requiredChecks/g, "$1");
6403
+ transforms.push("dropped `...repo.requiredChecks`");
6404
+ }
6405
+ }
6406
+ if (/^\s*workflows\s*:/m.test(s)) {
6407
+ s = s.replace(/(\n\s*)workflows(\s*:\s*\[)/, "$1tasks$2");
6408
+ s = s.replace(/\.\.\.workflows\b/g, "...preset.tasks");
6409
+ transforms.push("`workflows:` → `tasks:` (`...workflows` → `...preset.tasks`)");
6410
+ warnings.push("review the `tasks` array — a bare workflow name that should gate merges now needs `{ name, required: true }`");
6411
+ }
6412
+ return {
6413
+ content: s,
6414
+ changed: s !== content,
6415
+ transforms,
6416
+ warnings
6417
+ };
6418
+ }
6419
+ async function runUpgradeDeps(input = {}) {
6420
+ const print = input.print ?? ((line) => console.log(line));
6421
+ const logger = input.logger ?? getLogger();
6422
+ const cwd = input.cwd ?? process.cwd();
6423
+ const dryRun = input.dryRun ?? false;
6424
+ const pinsOnly = input.pinsOnly ?? false;
6425
+ const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
6426
+ const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
6427
+ const fetchLatest = input.fetchLatest ?? fetchLatestFromNpm;
6428
+ logger.info({
6429
+ dryRun: dryRun || void 0,
6430
+ pinsOnly: pinsOnly || void 0
6431
+ }, "upgrade deps: start");
6432
+ print(`Upgrading @theholocron/* dependencies${dryRun ? " (dry-run)" : ""}…`);
6433
+ const wsPath = join(cwd, "pnpm-workspace.yaml");
6434
+ let bumps = [];
6435
+ try {
6436
+ const result = await bumpCatalogs(_readFile(wsPath), fetchLatest);
6437
+ bumps = result.bumps;
6438
+ if (bumps.length > 0) {
6439
+ if (!dryRun) _writeFile(wsPath, result.content);
6440
+ print("");
6441
+ print(` pnpm-workspace.yaml — ${bumps.length} pin${bumps.length === 1 ? "" : "s"}`);
6442
+ for (const b of bumps) print(` ${dryRun ? "~" : "✓"} ${b.pkg} ${b.from} → ${b.to}`);
6443
+ } else print(" · pnpm-workspace.yaml — @theholocron/* pins already current");
6444
+ } catch {
6445
+ print(" · no pnpm-workspace.yaml — skipping catalog pins");
6446
+ }
6447
+ let configMigrated = false;
6448
+ let configWarnings = [];
6449
+ if (!pinsOnly) for (const name of ["holocron.config.ts", "holocron.config.js"]) {
6450
+ let cfg;
6451
+ try {
6452
+ cfg = _readFile(join(cwd, name));
6453
+ } catch {
6454
+ continue;
6455
+ }
6456
+ const m = migrateConfig(cfg);
6457
+ if (!m.changed) {
6458
+ print(` · ${name} — already on the current preset API`);
6459
+ break;
6460
+ }
6461
+ if (!dryRun) _writeFile(join(cwd, name), m.content);
6462
+ configMigrated = true;
6463
+ configWarnings = m.warnings;
6464
+ print("");
6465
+ print(` ${name} — ${dryRun ? "would migrate" : "migrated"} to the 8.x preset API`);
6466
+ for (const t of m.transforms) print(` ${dryRun ? "~" : "✓"} ${t}`);
6467
+ for (const w of m.warnings) print(` ⚠ ${w}`);
6468
+ break;
6469
+ }
6470
+ print("");
6471
+ if (bumps.length === 0 && !configMigrated) print("Nothing to do — already up to date.");
6472
+ else {
6473
+ print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron ci\``);
6474
+ if (configMigrated) print(" review holocron.config.ts before committing.");
6475
+ }
6476
+ const status = dryRun ? "dry-run" : "ok";
6477
+ logger.info({
6478
+ bumps: bumps.length,
6479
+ configMigrated,
6480
+ warnings: configWarnings.length,
6481
+ status
6482
+ }, "upgrade deps: done");
6483
+ return {
6484
+ status,
6485
+ bumps,
6486
+ configMigrated,
6487
+ configWarnings
6488
+ };
6489
+ }
6490
+ //#endregion
6237
6491
  //#region src/commands/upgrade-node.ts
6238
6492
  const SKIP_DIRS = /* @__PURE__ */ new Set([
6239
6493
  "node_modules",
@@ -7433,6 +7687,20 @@ try {
7433
7687
  if (report.message) getLogger().error(`upgrade node: ${report.message}`);
7434
7688
  process.exitCode = 1;
7435
7689
  }
7690
+ }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
7691
+ type: "boolean",
7692
+ default: false,
7693
+ describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
7694
+ }), async (argv) => {
7695
+ const report = await runUpgradeDeps({
7696
+ cwd: argv.cwd,
7697
+ dryRun: argv.dryRun,
7698
+ pinsOnly: argv.pinsOnly
7699
+ });
7700
+ if (report.status === "fail") {
7701
+ if (report.message) getLogger().error(`upgrade deps: ${report.message}`);
7702
+ process.exitCode = 1;
7703
+ }
7436
7704
  }).demandCommand(1, "Run `holocron upgrade --help` to see available upgrade subcommands."), () => {}).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [value]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
7437
7705
  type: "string",
7438
7706
  demandOption: true