@theholocron/cli 5.0.0-alpha.13 → 5.0.0-alpha.134

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/dist/cli.mjs CHANGED
@@ -1,29 +1,32 @@
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, resolve } from "node:path";
5
- import { checkbox, input, select } from "@inquirer/prompts";
6
- import { KNOWN_WORKFLOWS, createAstromech, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
4
+ import { homedir, hostname, tmpdir, userInfo } from "node:os";
5
+ import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
6
+ import { checkbox, confirm, input, select } from "@inquirer/prompts";
7
+ import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
8
  import { loadTasksConfig } from "@theholocron/astromech/config";
9
+ import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
8
10
  import yargs from "yargs";
9
11
  import { hideBin } from "yargs/helpers";
10
12
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
11
13
  import { createEnvLookup } from "@theholocron/env-utils";
12
14
  import { Entry, findCredentials } from "@napi-rs/keyring";
13
15
  import { pathToFileURL } from "node:url";
14
- import { createLogger, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
15
- import ora from "ora";
16
+ import { errorMessage } from "@theholocron/misc-utils";
17
+ import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
16
18
  import chalk from "chalk";
19
+ import ora from "ora";
17
20
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
18
- import { homedir, hostname, userInfo } from "node:os";
19
21
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
20
22
  import { createHash } from "node:crypto";
21
23
  import { generateReadme } from "@theholocron/components-doc/markdown";
22
24
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
23
25
  import { createGitHubClient } from "@theholocron/github-client";
24
26
  import { PostHogSink } from "@theholocron/observability/analytics";
25
- import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
26
27
  import { SentrySink } from "@theholocron/observability/errors";
28
+ import load from "@commitlint/load";
29
+ import lint from "@commitlint/lint";
27
30
  import { promisify } from "node:util";
28
31
  import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
29
32
  import search from "@inquirer/search";
@@ -181,6 +184,7 @@ const CARDINALITY = {
181
184
  analytics: "many",
182
185
  errors: "single",
183
186
  logs: "single",
187
+ monitoring: "single",
184
188
  wiki: "single",
185
189
  workers: "single"
186
190
  };
@@ -295,46 +299,157 @@ function resolveConfig(raw) {
295
299
  docs,
296
300
  env: raw.env,
297
301
  log: raw.log,
298
- telemetry: raw.telemetry
302
+ telemetry: raw.telemetry,
303
+ eslint: raw.eslint
299
304
  };
300
305
  }
301
306
  //#endregion
307
+ //#region src/commands/contexts.ts
308
+ /**
309
+ * Command → context. Keys are the full command path as it lands in
310
+ * yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
311
+ * otherwise; a longer, more specific key wins over its parent.
312
+ *
313
+ * - **`global`** — needs nothing but the CLI binary. Works anywhere.
314
+ * - **`repo-aware`** — reads `./holocron.config` + `./package.json`
315
+ * relative to cwd, but never touches the plugin loader.
316
+ * - **`workspace`** — needs the configured providers' plugin packages to
317
+ * resolve (devDeps in a repo, or `pnpm exec`).
318
+ */
319
+ const COMMAND_CONTEXTS = {
320
+ clone: "global",
321
+ "new repo": "global",
322
+ "new plugin": "global",
323
+ "repo upgrade node": "global",
324
+ "repo upgrade deps": "global",
325
+ "auth set": "global",
326
+ "auth unset": "global",
327
+ "auth list": "global",
328
+ "auth check": "global",
329
+ "package bump-versions": "global",
330
+ "package publish": "global",
331
+ "skills install": "global",
332
+ "skills remove": "global",
333
+ "skills update": "global",
334
+ "run lint commit-msg": "global",
335
+ run: "repo-aware",
336
+ "run ci": "repo-aware",
337
+ "deploy on-release": "repo-aware",
338
+ "config show": "repo-aware",
339
+ "repo sync readme": "repo-aware",
340
+ doctor: "workspace",
341
+ "repo setup": "workspace",
342
+ "secrets set": "workspace",
343
+ "secrets sync": "workspace",
344
+ deploy: "workspace",
345
+ "deploy cleanup-preview": "workspace",
346
+ "repo sync": "workspace",
347
+ "sync github": "workspace"
348
+ };
349
+ /**
350
+ * The context for a command name from `argv._`. Falls back to the leading
351
+ * verb (`"auth foo"` → `"auth"`) and finally `undefined` for a name the
352
+ * map doesn't know — callers treat unknown as "don't guard".
353
+ */
354
+ function contextForCommand(name) {
355
+ const map = COMMAND_CONTEXTS;
356
+ const tokens = name.split(" ").filter(Boolean);
357
+ for (let n = tokens.length; n > 0; n--) {
358
+ const ctx = map[tokens.slice(0, n).join(" ")];
359
+ if (ctx) return ctx;
360
+ }
361
+ }
362
+ /** Command names in a given context, in registration order. */
363
+ function commandsInContext(context) {
364
+ return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
365
+ }
366
+ //#endregion
367
+ //#region src/ui/style.ts
368
+ const style = {
369
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
370
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
371
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
372
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
373
+ /** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
374
+ skip: (msg) => chalk.dim(`· ${msg}`),
375
+ hint: (msg) => chalk.dim(msg),
376
+ dim: (msg) => chalk.dim(msg),
377
+ header: (msg) => chalk.bold(msg)
378
+ };
379
+ //#endregion
302
380
  //#region src/logger.ts
303
381
  /**
382
+ * Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
383
+ * node`, …) — ADR-0012. These commands aren't "about" any repo, so their
384
+ * logs must never depend on `$PWD` or which repo's `.envrc` happens to be
385
+ * active; they always ship to holocron's own dataset instead of whatever
386
+ * `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
387
+ * `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
388
+ * per-repo override.
389
+ */
390
+ const GLOBAL_AXIOM_DATASET = "holocron-global";
391
+ /**
304
392
  * Resolve the explicit level to hand to `createLogger`, in priority order:
305
393
  *
306
- * 1. `--verbose` → `"debug"` 2. `--quiet` → `"error"`
307
- * 3. `HOLOCRON_LOG_LEVEL` env var
308
- * 4. `holocron.config` `log.level` (`configLevel`)
394
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
395
+ * 4. `HOLOCRON_LOG_LEVEL` env var
396
+ * 5. `holocron.config` `log.level` (`configLevel`)
309
397
  *
310
398
  * Returns `undefined` when nothing applies — `createLogger` then defaults
311
399
  * to `"info"`. Resolving the full chain here (rather than passing
312
400
  * `configLevel` straight through) keeps config below the env var.
313
401
  */
314
402
  function resolveLogLevel(argv, configLevel) {
403
+ if (argv.log) return argv.log;
315
404
  if (argv.verbose) return "debug";
316
405
  if (argv.quiet) return "error";
317
406
  return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
318
407
  }
408
+ /**
409
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
410
+ * at all, independent of level — the `@theholocron/observability`
411
+ * `consoleOutput` override.
412
+ *
413
+ * Local/interactive runs are quiet by default: any raw structured logger
414
+ * output (colorized or not) stays off regardless of the resolved level,
415
+ * unless the user explicitly asked to see it via `--log <level>`,
416
+ * `--verbose`, or `--quiet`. CI is untouched either way — returning
417
+ * `undefined` there preserves `createLogger`'s own `ci`/`tty`
418
+ * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
419
+ * unaffected by this flag.
420
+ */
421
+ function resolveConsoleOutput(argv) {
422
+ if (argv.log || argv.verbose || argv.quiet) return true;
423
+ return isCI() ? void 0 : false;
424
+ }
319
425
  let root;
320
426
  let rootLevel;
321
427
  let rootCommand;
322
428
  let rootAxiomKey;
323
429
  /**
324
- * Resolve Axiom credentials for the CLI. Env vars win — same contract as
325
- * `@theholocron/observability`'s `resolveAxiomFromEnv`. Failing that, the CLI-only
326
- * bridge pairs the OS-keyring token (`axiom.<org>` then bare `axiom`) with a
327
- * dataset from `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or
328
- * `holocron.config` `log.axiom.dataset`. Returns `undefined` unless both a
329
- * token and a dataset are found.
430
+ * Resolve Axiom credentials for the CLI.
431
+ *
432
+ * A `global`-context command (ADR-0012) always resolves to
433
+ * {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
434
+ * `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
435
+ * isn't "about" any repo, so the active directory's dataset is never the
436
+ * right answer. Everything else keeps the existing chain: env vars win
437
+ * (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
438
+ * falling back to the CLI-only bridge that pairs the OS-keyring token
439
+ * (`axiom.<org>` then bare `axiom`) with a dataset from
440
+ * `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
441
+ * Returns `undefined` unless both a token and a dataset are found.
330
442
  */
331
443
  function resolveCliAxiom(opts) {
332
- const fromEnv = resolveAxiomFromEnv();
333
- if (fromEnv) return fromEnv;
334
- const dataset = env.get("HOLOCRON_AXIOM_DATASET") || env.get("AXIOM_DATASET") || opts.configAxiomDataset;
444
+ const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
445
+ if (!isGlobal) {
446
+ const fromEnv = resolveAxiomFromEnv();
447
+ if (fromEnv) return fromEnv;
448
+ }
449
+ const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : env.get("HOLOCRON_AXIOM_DATASET") || env.get("AXIOM_DATASET") || opts.configAxiomDataset;
335
450
  if (!dataset) return void 0;
336
451
  const org = opts.org ?? env.get("HOLOCRON_ORG");
337
- const token = (org ? getToken(`axiom.${org}`) : null) ?? getToken("axiom");
452
+ const token = env.get("HOLOCRON_AXIOM_TOKEN") || env.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
338
453
  return token ? {
339
454
  dataset,
340
455
  token
@@ -354,13 +469,15 @@ function buildCliLogger(argv, opts = {}) {
354
469
  const { command, configLevel } = opts;
355
470
  if (command) rootCommand = command;
356
471
  const level = resolveLogLevel(argv, configLevel);
472
+ const consoleOutput = resolveConsoleOutput(argv);
357
473
  const axiom = resolveCliAxiom(opts);
358
474
  const axiomKey = axiom?.dataset;
359
- const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet;
475
+ const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
360
476
  if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
361
477
  const built = createLogger({
362
478
  ...level ? { level } : {},
363
- ...axiom ? { axiom } : {}
479
+ ...axiom ? { axiom } : {},
480
+ ...consoleOutput !== void 0 ? { consoleOutput } : {}
364
481
  });
365
482
  root = {
366
483
  logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
@@ -371,9 +488,35 @@ function buildCliLogger(argv, opts = {}) {
371
488
  }
372
489
  return root;
373
490
  }
374
- /** Lazily-memoized `Logger` for module-level call sites with no `argv` in scope. */
491
+ /**
492
+ * Lazily-memoized `Logger` for module-level call sites with no `argv` in
493
+ * scope — notably yargs' own `.fail()` handler, which fires on a
494
+ * pre-middleware validation failure (a missing required argument, an
495
+ * unknown command) and therefore runs before `buildCliLogger` has ever
496
+ * built the real root. Without an explicit `consoleOutput` here, that
497
+ * fallback would use `createLogger`'s bare auto-detection (pretty on any
498
+ * local TTY) and leak a structured block even though no flag asked for one
499
+ * — so it resolves the same "quiet unless CI" default `buildCliLogger`
500
+ * would, as if no flags were passed.
501
+ */
375
502
  function getLogger() {
376
- return (root ??= createLogger()).logger;
503
+ return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
504
+ }
505
+ /**
506
+ * Report a fatal, command-ending CLI error: always printed to the console
507
+ * (regardless of `consoleOutput` suppression) via `style.fail`, and also
508
+ * sent through the structured logger for Axiom/telemetry visibility.
509
+ *
510
+ * Use this when a handler is returning or exiting right here with nothing
511
+ * else for the user to see — a resolved `--token` failure, a yargs
512
+ * validation failure, a thrown domain error. Don't use it for a per-step
513
+ * warning inside a multi-step orchestrator that already prints its own
514
+ * `ok / fail / skip` summary — that summary is the user-facing signal;
515
+ * the per-step reason belongs in the structured log alone.
516
+ */
517
+ function reportError(message, log = getLogger()) {
518
+ console.error(style.fail(message));
519
+ log.error(message);
377
520
  }
378
521
  /** The current root logger's correlation id, if a root has been built. */
379
522
  function getRunId() {
@@ -491,7 +634,7 @@ var PluginLoader = class {
491
634
  /** Internal — invoke a plugin's capability factory and return the impl. */
492
635
  async loadOne(key, tuple) {
493
636
  const mod = await this.importer(tuple.packageName).catch((err) => {
494
- throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
637
+ throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${errorMessage(err)}`);
495
638
  });
496
639
  if (isPluginModule(mod)) {
497
640
  const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
@@ -532,7 +675,8 @@ var PluginLoader = class {
532
675
  /** Default importer — resolves from cwd first so the global CLI finds project plugins. */
533
676
  const defaultImporter$1 = async (pkg) => {
534
677
  try {
535
- return import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
678
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
679
+ return import(pathToFileURL(resolved).href);
536
680
  } catch {
537
681
  return import(pkg);
538
682
  }
@@ -632,17 +776,6 @@ async function withSpinner(label, fn) {
632
776
  }
633
777
  }
634
778
  //#endregion
635
- //#region src/ui/style.ts
636
- const style = {
637
- success: (msg) => `${chalk.green("✓")} ${msg}`,
638
- warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
639
- fail: (msg) => `${chalk.red("✗")} ${msg}`,
640
- step: (msg) => `${chalk.cyan("→")} ${msg}`,
641
- hint: (msg) => chalk.dim(msg),
642
- dim: (msg) => chalk.dim(msg),
643
- header: (msg) => chalk.bold(msg)
644
- };
645
- //#endregion
646
779
  //#region src/commands/auth.ts
647
780
  /**
648
781
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
@@ -664,7 +797,8 @@ const style = {
664
797
  */
665
798
  const defaultImporter = async (pkg) => {
666
799
  try {
667
- return await import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
800
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
801
+ return await import(pathToFileURL(resolved).href);
668
802
  } catch {
669
803
  return await import(pkg);
670
804
  }
@@ -733,7 +867,7 @@ async function runAuthSet(input) {
733
867
  subject = verified.subject;
734
868
  } else print(style.warn(`${provider} plugin has no verifyToken; storing without verification`));
735
869
  } catch (err) {
736
- const msg = err instanceof Error ? err.message : String(err);
870
+ const msg = errorMessage(err);
737
871
  print(style.warn(`cannot verify token — failed to load ${packageName}: ${msg}`));
738
872
  print(style.hint(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`));
739
873
  }
@@ -850,7 +984,7 @@ async function runAuthCheck(input) {
850
984
  message: verified.message
851
985
  };
852
986
  } catch (err) {
853
- const msg = err instanceof Error ? err.message : String(err);
987
+ const msg = errorMessage(err);
854
988
  if (isModuleNotFound(err)) {
855
989
  print(style.warn(`${keyringKey}: token present — verification skipped (plugin not available here)`));
856
990
  logger.info({
@@ -937,7 +1071,7 @@ async function runCleanupPreview(input) {
937
1071
  // c8 ignore next -- real PluginLoader construction is integration-level; unit tests always supply loader
938
1072
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
939
1073
  await loader.load();
940
- assertPluginsResolvable(loader, "cleanup-preview");
1074
+ assertPluginsResolvable(loader, "deploy cleanup-preview");
941
1075
  logger.info({
942
1076
  pr: input.prNumber,
943
1077
  project: input.project
@@ -949,7 +1083,7 @@ async function runCleanupPreview(input) {
949
1083
  try {
950
1084
  pr = await source.getPullRequest(input.prNumber, input.repo);
951
1085
  } catch (err) {
952
- const message = err instanceof Error ? err.message : String(err);
1086
+ const message = errorMessage(err);
953
1087
  throw new Error(`Failed to fetch PR #${input.prNumber}: ${message}`, { cause: err });
954
1088
  }
955
1089
  const branch = `${(input.repo ?? input.loaded.resolved.repo?.name ?? "").split("/").pop()}-pr-${pr.number}`;
@@ -966,7 +1100,7 @@ async function runCleanupPreview(input) {
966
1100
  try {
967
1101
  deployments = await deploy.listPreviewDeployments(input.project, branch);
968
1102
  } catch (err) {
969
- const message = err instanceof Error ? err.message : String(err);
1103
+ const message = errorMessage(err);
970
1104
  throw new Error(`Failed to list deployments: ${message}`, { cause: err });
971
1105
  }
972
1106
  if (deployments.length === 0) {
@@ -1045,7 +1179,7 @@ async function runCleanupPreview(input) {
1045
1179
  status: "ok"
1046
1180
  };
1047
1181
  } catch (err) {
1048
- const message = err instanceof Error ? err.message : String(err);
1182
+ const message = errorMessage(err);
1049
1183
  print(style.fail(message));
1050
1184
  logger.warn({
1051
1185
  pr: pr.number,
@@ -1065,13 +1199,56 @@ async function runCleanupPreview(input) {
1065
1199
  }
1066
1200
  }
1067
1201
  //#endregion
1202
+ //#region src/ui/status-row.ts
1203
+ /** Right-pads `s` to `width` with spaces — never truncates. */
1204
+ function pad(s, width) {
1205
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
1206
+ }
1207
+ /**
1208
+ * One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
1209
+ * then `columns` joined with a two-space gap (blank columns dropped, so a
1210
+ * row that has nothing for its last column doesn't trail whitespace).
1211
+ * Column padding (`pad`) is the caller's job — `doctor` pads capability and
1212
+ * provider to a fixed width; `clone`'s repo names and paths don't, since
1213
+ * they vary too widely for fixed-width alignment to read cleanly.
1214
+ *
1215
+ * Shared by `doctor` (capability / provider / message) and `clone` (repo /
1216
+ * destination / reason) so every status table in the CLI reads the same way.
1217
+ */
1218
+ function statusRow(status, columns) {
1219
+ const joined = columns.filter((c) => c.length > 0).join(" ");
1220
+ return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
1221
+ }
1222
+ //#endregion
1068
1223
  //#region src/commands/clone.ts
1224
+ /**
1225
+ * Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
1226
+ * `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
1227
+ * repo. The repo half is never silently discarded: if it's there, it's used.
1228
+ * A trailing slash with nothing after it (`"theholocron/"`) is treated the
1229
+ * same as a bare org.
1230
+ */
1231
+ function parseCloneTarget(raw) {
1232
+ const trimmed = raw.trim();
1233
+ const slash = trimmed.indexOf("/");
1234
+ if (slash === -1) return { org: trimmed };
1235
+ const org = trimmed.slice(0, slash);
1236
+ const repo = trimmed.slice(slash + 1);
1237
+ return repo ? {
1238
+ org,
1239
+ repo
1240
+ } : { org };
1241
+ }
1069
1242
  function encodeTokenForGitHttpAuth(token) {
1070
1243
  const trimmed = token.trim();
1071
1244
  if (!trimmed) throw new Error("empty token");
1072
1245
  if (/[\u0000-\u001F\u007F\s]/.test(trimmed)) throw new Error("token contains whitespace or control characters");
1073
1246
  return encodeURIComponent(trimmed);
1074
1247
  }
1248
+ /** Strip a leading dot so a hidden repo (e.g. `.github`) is still visible in Finder once cloned. */
1249
+ function repoDirName(repo) {
1250
+ return repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
1251
+ }
1075
1252
  async function listOrgRepos(org, token, fetchFn) {
1076
1253
  const repos = [];
1077
1254
  let url = `https://api.github.com/orgs/${org}/repos?per_page=100&type=all`;
@@ -1088,6 +1265,16 @@ async function listOrgRepos(org, token, fetchFn) {
1088
1265
  }
1089
1266
  return repos;
1090
1267
  }
1268
+ async function getRepo(org, repo, token, fetchFn) {
1269
+ const res = await fetchFn(`https://api.github.com/repos/${org}/${repo}`, { headers: {
1270
+ Authorization: `Bearer ${token}`,
1271
+ Accept: "application/vnd.github+json",
1272
+ "X-GitHub-Api-Version": "2022-11-28"
1273
+ } });
1274
+ if (res.status === 404) throw new Error(`${org}/${repo} not found — check the repo name and that the token can see it`);
1275
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText} — check that the token has repo:read scope`);
1276
+ return await res.json();
1277
+ }
1091
1278
  async function runClone(input) {
1092
1279
  const print = input.print ?? ((line) => console.log(line));
1093
1280
  const logger = input.logger ?? getLogger();
@@ -1096,6 +1283,7 @@ async function runClone(input) {
1096
1283
  const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
1097
1284
  logger.info({
1098
1285
  org: input.org,
1286
+ repo: input.repo,
1099
1287
  targetDir,
1100
1288
  dryRun: dryRun || void 0
1101
1289
  }, "clone: start");
@@ -1105,18 +1293,21 @@ async function runClone(input) {
1105
1293
  stdio: "inherit"
1106
1294
  }).status };
1107
1295
  });
1108
- print(style.header(`Holocron clone — org=${input.org} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
1109
- if (!existsSync(targetDir)) if (dryRun) print(style.dim(` would create ${targetDir}`));
1110
- else mkdirSync(targetDir, { recursive: true });
1296
+ print("");
1297
+ if (!existsSync(targetDir)) {
1298
+ if (dryRun) print(style.dim(` would create ${targetDir}`));
1299
+ else mkdirSync(targetDir, { recursive: true });
1300
+ }
1111
1301
  let repos;
1112
1302
  try {
1113
- repos = await listOrgRepos(input.org, input.token, fetchFn);
1303
+ repos = input.repo ? [await getRepo(input.org, input.repo, input.token, fetchFn)] : await listOrgRepos(input.org, input.token, fetchFn);
1114
1304
  } catch (err) {
1115
- const message = err instanceof Error ? err.message : String(err);
1305
+ const message = errorMessage(err);
1116
1306
  logger.warn({
1117
1307
  org: input.org,
1308
+ repo: input.repo,
1118
1309
  reason: message
1119
- }, "clone: failed to list org repos");
1310
+ }, "clone: failed to list repos");
1120
1311
  return {
1121
1312
  status: "fail",
1122
1313
  cloned: 0,
@@ -1125,13 +1316,39 @@ async function runClone(input) {
1125
1316
  message
1126
1317
  };
1127
1318
  }
1319
+ if (!input.repo && !dryRun && input.confirmWholeOrg) {
1320
+ const alreadyCloned = repos.filter((r) => existsSync(join(targetDir, repoDirName(r)))).length;
1321
+ const scope = {
1322
+ total: repos.length,
1323
+ alreadyCloned,
1324
+ toClone: repos.length - alreadyCloned
1325
+ };
1326
+ if (!await input.confirmWholeOrg(scope)) {
1327
+ print(style.dim(" aborted — nothing cloned"));
1328
+ logger.info({
1329
+ org: input.org,
1330
+ ...scope
1331
+ }, "clone: aborted by user");
1332
+ return {
1333
+ status: "aborted",
1334
+ cloned: 0,
1335
+ skipped: 0,
1336
+ failed: 0,
1337
+ message: "aborted by user"
1338
+ };
1339
+ }
1340
+ }
1128
1341
  let cloned = 0;
1129
1342
  let skipped = 0;
1130
1343
  let failed = 0;
1131
1344
  for (const repo of repos) {
1132
- const dest = join(targetDir, repo.name.startsWith(".") ? repo.name.slice(1) : repo.name);
1345
+ const dest = join(targetDir, repoDirName(repo));
1133
1346
  if (existsSync(dest)) {
1134
- print(style.dim(` skip ${repo.full_name}`));
1347
+ print(statusRow("skip", [
1348
+ repo.full_name,
1349
+ dest,
1350
+ "already exists"
1351
+ ]));
1135
1352
  skipped++;
1136
1353
  continue;
1137
1354
  }
@@ -1140,10 +1357,14 @@ async function runClone(input) {
1140
1357
  cloned++;
1141
1358
  continue;
1142
1359
  }
1143
- print(style.step(` clone ${repo.full_name}`));
1360
+ print(style.step(` clone ${repo.full_name} → ${dest}`));
1144
1361
  const { clone_url } = repo;
1145
1362
  if (!clone_url.startsWith("https://github.com/")) {
1146
- print(style.fail(` failed ${repo.full_name} — unexpected clone URL: ${clone_url}`));
1363
+ print(statusRow("fail", [
1364
+ repo.full_name,
1365
+ dest,
1366
+ `unexpected clone URL: ${clone_url}`
1367
+ ]));
1147
1368
  failed++;
1148
1369
  continue;
1149
1370
  }
@@ -1151,7 +1372,11 @@ async function runClone(input) {
1151
1372
  try {
1152
1373
  encodedToken = encodeTokenForGitHttpAuth(input.token);
1153
1374
  } catch (err) {
1154
- print(style.fail(` failed ${repo.full_name} — invalid token format: ${err instanceof Error ? err.message : String(err)}`));
1375
+ print(statusRow("fail", [
1376
+ repo.full_name,
1377
+ dest,
1378
+ `invalid token format: ${errorMessage(err)}`
1379
+ ]));
1155
1380
  failed++;
1156
1381
  continue;
1157
1382
  }
@@ -1161,23 +1386,29 @@ async function runClone(input) {
1161
1386
  `https://x-access-token:${encodedToken}@github.com/${clone_url.slice(19)}`,
1162
1387
  dest
1163
1388
  ], { cwd: targetDir }).status !== 0) {
1164
- print(style.fail(` failed ${repo.full_name}`));
1389
+ print(statusRow("fail", [repo.full_name, dest]));
1165
1390
  failed++;
1166
1391
  } else {
1167
- print(style.success(` cloned ${repo.name}`));
1392
+ print(statusRow("ok", [repo.full_name, dest]));
1168
1393
  cloned++;
1169
1394
  }
1170
1395
  }
1171
- const summary = `${cloned} cloned, ${skipped} skipped, ${failed} failed`;
1172
- print(dryRun ? style.dim(`\n dry-run: ${summary}`) : failed > 0 ? style.fail(`\n ${summary}`) : style.success(`\n ${summary}`));
1396
+ const summary = `${cloned} cloned, ${failed} failed, ${skipped} skipped`;
1397
+ print("");
1398
+ print(dryRun ? style.dim(` dry-run: ${summary}`) : failed > 0 ? style.fail(` ${summary}`) : style.success(` ${summary}`));
1173
1399
  const status = dryRun ? "dry-run" : failed > 0 ? "fail" : "ok";
1174
1400
  logger[failed > 0 ? "warn" : "info"]({
1175
1401
  org: input.org,
1402
+ repo: input.repo,
1176
1403
  status,
1177
1404
  cloned,
1178
1405
  skipped,
1179
1406
  failed
1180
1407
  }, "clone: done");
1408
+ if (input.confirmOpen) {
1409
+ const openTarget = input.repo ? join(targetDir, repoDirName(repos[0])) : targetDir;
1410
+ if (existsSync(openTarget)) await input.confirmOpen(openTarget, dryRun && cloned > 0);
1411
+ }
1181
1412
  return {
1182
1413
  status,
1183
1414
  cloned,
@@ -1186,53 +1417,81 @@ async function runClone(input) {
1186
1417
  };
1187
1418
  }
1188
1419
  //#endregion
1189
- //#region src/commands/contexts.ts
1420
+ //#region src/commands/deploy.ts
1190
1421
  /**
1191
- * Command → context. Keys are the command name as it lands in yargs'
1192
- * `argv._` — the full path for sub-commands (`"auth set"`, `"upgrade
1193
- * node"`), the bare verb otherwise.
1422
+ * `holocron deploy` — trigger a deployment via the configured
1423
+ * `deployment` capability.
1194
1424
  *
1195
- * - **`global`** — needs nothing but the CLI binary. Works anywhere.
1196
- * - **`repo-aware`** — reads `./holocron.config` + `./package.json`
1197
- * relative to cwd, but never touches the plugin loader.
1198
- * - **`workspace`** — needs the configured providers' plugin packages to
1199
- * resolve (devDeps in a repo, or `pnpm exec`).
1425
+ * Two modes, one command: `runDeploy` (the default — a Git branch,
1426
+ * `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
1427
+ * a local directory of source files, `deployFunction()`, no linked
1428
+ * repo required — for a consumer with no Git history to deploy from,
1429
+ * e.g. Sentinel, shipped as an npm package). Separate functions, not
1430
+ * one branching `runDeploy`, because their inputs genuinely differ
1431
+ * (`branch` vs `dir`) and their result shapes differ too
1432
+ * (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
1433
+ * is deliberately minimal, same as `DeployScriptResult` for Workers).
1434
+ *
1435
+ * Errors clearly when `deployment` isn't loaded (no provider declared
1436
+ * in `holocron.config.json`), or when the configured provider doesn't
1437
+ * implement `deployFunction` (it's optional on `Deployment` — not
1438
+ * every provider has a files-based deploy API).
1439
+ *
1440
+ * Dry-run skips the actual deploy call but still verifies the
1441
+ * deployment capability (and, for `--files`, `deployFunction`) is
1442
+ * present, so operators can sanity-check the wiring without spinning
1443
+ * up a build.
1444
+ *
1445
+ * **Both modes wait for the deployment to finish** (holocron#911): a
1446
+ * provider accepting the upload/trigger is not the deployment succeeding.
1447
+ * Vercel builds asynchronously — an `npm install` failure there leaves
1448
+ * the deployment in `ERROR` while production keeps serving the previous
1449
+ * one, and this command used to report `ok` regardless (found live:
1450
+ * Sentinel's holocron#910 deploy "succeeded" in CI, never went live). So
1451
+ * after triggering, {@link waitForDeployment} polls
1452
+ * `Deployment.getDeployment()` until a terminal state; only `ready` is
1453
+ * `ok`, while `error` / `cancelled` / a timeout are `fail` (non-zero exit
1454
+ * via `cli.ts`).
1200
1455
  */
1201
- const COMMAND_CONTEXTS = {
1202
- version: "global",
1203
- clone: "global",
1204
- new: "global",
1205
- "upgrade node": "global",
1206
- "upgrade deps": "global",
1207
- "plugin create": "global",
1208
- "auth set": "global",
1209
- "auth unset": "global",
1210
- "auth list": "global",
1211
- "auth check": "global",
1212
- "bump-versions": "global",
1213
- publish: "global",
1214
- "skills install": "global",
1215
- "skills remove": "global",
1216
- "skills update": "global",
1217
- run: "repo-aware",
1218
- ci: "repo-aware",
1219
- "config show": "repo-aware",
1220
- "sync-readme": "repo-aware",
1221
- doctor: "workspace",
1222
- setup: "workspace",
1223
- "secret set": "workspace",
1224
- "secrets sync": "workspace",
1225
- deploy: "workspace",
1226
- "cleanup-preview": "workspace",
1227
- sync: "workspace",
1228
- "sync-github": "workspace"
1229
- };
1230
- /** Command names in a given context, in registration order. */
1231
- function commandsInContext(context) {
1232
- return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
1456
+ const DEFAULT_WAIT_TIMEOUT_MS = 6e5;
1457
+ const DEFAULT_WAIT_INTERVAL_MS = 5e3;
1458
+ const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
1459
+ "ready",
1460
+ "error",
1461
+ "cancelled"
1462
+ ]);
1463
+ /**
1464
+ * Polls `getDeployment(id)` until the deployment reaches a terminal status
1465
+ * (`ready` / `error` / `cancelled`). Throws on timeout — the caller turns
1466
+ * that into a `fail` report, same as a provider throwing mid-trigger.
1467
+ */
1468
+ async function waitForDeployment(deploy, deploymentId, options = {}) {
1469
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
1470
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
1471
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1472
+ const now = options.now ?? Date.now;
1473
+ const deadline = now() + timeoutMs;
1474
+ for (;;) {
1475
+ const record = await deploy.getDeployment(deploymentId);
1476
+ if (TERMINAL_STATUSES.has(record.status)) return record;
1477
+ if (now() >= deadline) throw new Error(`deployment ${deploymentId} still ${record.status} after ${Math.round(timeoutMs / 1e3)}s — check the provider's dashboard`);
1478
+ await sleep(intervalMs);
1479
+ }
1480
+ }
1481
+ /** One terminal, non-ready record as a single human-readable failure line. */
1482
+ function terminalFailure(record) {
1483
+ return `deployment ${record.id} ended ${record.status}${record.errorMessage ? `: ${record.errorMessage}` : ""}`;
1484
+ }
1485
+ /**
1486
+ * Provider `url` fields (Vercel's REST API, at least) come back as bare
1487
+ * hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
1488
+ * linkify and browsers won't navigate to unmodified. Printed output
1489
+ * should be a URL a human can click/paste; structured log fields keep
1490
+ * the raw provider value as-is.
1491
+ */
1492
+ function withScheme(url) {
1493
+ return /^https?:\/\//.test(url) ? url : `https://${url}`;
1233
1494
  }
1234
- //#endregion
1235
- //#region src/commands/deploy.ts
1236
1495
  async function runDeploy(input) {
1237
1496
  const print = input.print ?? ((line) => console.log(line));
1238
1497
  const logger = input.logger ?? getLogger();
@@ -1259,23 +1518,43 @@ async function runDeploy(input) {
1259
1518
  };
1260
1519
  }
1261
1520
  try {
1262
- const record = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
1521
+ const triggered = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
1263
1522
  projectId: input.projectId,
1264
1523
  branch: input.branch,
1265
1524
  ...input.target ? { target: input.target } : {}
1266
1525
  }));
1267
- print(` ${style.success(`${record.status} — ${record.url}`)}`);
1526
+ logger.info({
1527
+ status: triggered.status,
1528
+ url: triggered.url,
1529
+ id: triggered.id
1530
+ }, "deploy: triggered");
1531
+ const record = TERMINAL_STATUSES.has(triggered.status) ? triggered : await withSpinner(`Waiting for ${withScheme(triggered.url)} to finish…`, () => waitForDeployment(deploy, triggered.id, input.wait));
1532
+ if (record.status !== "ready") {
1533
+ const message = terminalFailure(record);
1534
+ print(` ${style.fail(`${message} — ${withScheme(triggered.url)}`)}`);
1535
+ logger.warn({
1536
+ id: record.id,
1537
+ status: record.status,
1538
+ reason: record.errorMessage
1539
+ }, "deploy: failed");
1540
+ return {
1541
+ deployment: record,
1542
+ status: "fail",
1543
+ message
1544
+ };
1545
+ }
1546
+ print(` ${style.success(`${record.status} — ${withScheme(triggered.url)}`)}`);
1268
1547
  logger.info({
1269
1548
  status: record.status,
1270
- url: record.url,
1549
+ url: triggered.url,
1271
1550
  id: record.id
1272
- }, "deploy: triggered");
1551
+ }, "deploy: ready");
1273
1552
  return {
1274
1553
  deployment: record,
1275
1554
  status: "ok"
1276
1555
  };
1277
1556
  } catch (err) {
1278
- const message = err instanceof Error ? err.message : String(err);
1557
+ const message = errorMessage(err);
1279
1558
  print(` ${style.fail(message)}`);
1280
1559
  logger.warn({
1281
1560
  branch: input.branch,
@@ -1288,8 +1567,124 @@ async function runDeploy(input) {
1288
1567
  };
1289
1568
  }
1290
1569
  }
1570
+ const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
1571
+ ".git",
1572
+ "node_modules",
1573
+ ".turbo"
1574
+ ]);
1575
+ /** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
1576
+ function defaultWalkFiles$2(dir) {
1577
+ const results = [];
1578
+ for (const entry of readdirSync(dir)) {
1579
+ if (SKIP_DIRS$2.has(entry)) continue;
1580
+ const full = join(dir, entry);
1581
+ const stat = statSync(full);
1582
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
1583
+ else if (stat.isFile()) results.push(full);
1584
+ }
1585
+ return results;
1586
+ }
1587
+ /** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
1588
+ async function runDeployFromFiles(input) {
1589
+ const print = input.print ?? ((line) => console.log(line));
1590
+ const logger = input.logger ?? getLogger();
1591
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1592
+ await loader.load();
1593
+ assertPluginsResolvable(loader, "deploy");
1594
+ const dryRun = input.context.dryRun ?? false;
1595
+ const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
1596
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1597
+ const files = {};
1598
+ for (const abs of walkFiles(input.dir)) {
1599
+ const rel = relative(input.dir, abs).split(sep).join("/");
1600
+ files[rel] = readFile(abs);
1601
+ }
1602
+ const fileCount = Object.keys(files).length;
1603
+ logger.info({
1604
+ dir: input.dir,
1605
+ fileCount,
1606
+ target: input.target ?? "preview",
1607
+ projectId: input.projectId,
1608
+ dryRun: dryRun || void 0
1609
+ }, "deploy: start (files)");
1610
+ print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
1611
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
1612
+ const deploy = loader.get("deployment");
1613
+ if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
1614
+ if (dryRun) {
1615
+ const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
1616
+ print(` ${style.dim(`… ${message}`)}`);
1617
+ return {
1618
+ deployment: null,
1619
+ status: "dry-run",
1620
+ message
1621
+ };
1622
+ }
1623
+ try {
1624
+ const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
1625
+ files,
1626
+ ...input.target ? { target: input.target } : {}
1627
+ }));
1628
+ logger.info({
1629
+ url: result.url,
1630
+ id: result.deploymentId
1631
+ }, "deploy: triggered (files)");
1632
+ const record = await withSpinner(`Waiting for ${withScheme(result.url)} to finish…`, () => waitForDeployment(deploy, result.deploymentId, input.wait));
1633
+ if (record.status !== "ready") {
1634
+ const message = terminalFailure(record);
1635
+ print(` ${style.fail(`${message} — ${withScheme(result.url)}`)}`);
1636
+ logger.warn({
1637
+ id: record.id,
1638
+ status: record.status,
1639
+ reason: record.errorMessage
1640
+ }, "deploy: failed (files)");
1641
+ return {
1642
+ deployment: result,
1643
+ status: "fail",
1644
+ message
1645
+ };
1646
+ }
1647
+ print(` ${style.success(withScheme(result.url))}`);
1648
+ logger.info({
1649
+ url: result.url,
1650
+ id: result.deploymentId
1651
+ }, "deploy: ready (files)");
1652
+ return {
1653
+ deployment: result,
1654
+ status: "ok"
1655
+ };
1656
+ } catch (err) {
1657
+ const message = errorMessage(err);
1658
+ print(` ${style.fail(message)}`);
1659
+ logger.warn({
1660
+ dir: input.dir,
1661
+ reason: message
1662
+ }, "deploy: failed (files)");
1663
+ return {
1664
+ deployment: null,
1665
+ status: "fail",
1666
+ message
1667
+ };
1668
+ }
1669
+ }
1291
1670
  //#endregion
1292
1671
  //#region src/commands/doctor.ts
1672
+ /**
1673
+ * `holocron doctor` — load the config, instantiate every capability,
1674
+ * print a per-capability readiness report.
1675
+ *
1676
+ * Each capability gets a smoke check appropriate to its surface:
1677
+ * source → `whoami()`
1678
+ * issues → `doctor()` (rich report)
1679
+ * secrets → `listSecrets({ kind: 'repo' })`
1680
+ * ci → `listRuns({ limit: 1 })`
1681
+ * errors → `describe()`
1682
+ * logs → `whoami()` (dataset reachability) or `describe()`
1683
+ * others → just "loaded" (no smoke endpoint defined yet)
1684
+ *
1685
+ * Output is plain text via a passed-in `print` function so tests can
1686
+ * capture lines without ANSI / spinner noise.
1687
+ */
1293
1688
  async function runDoctor(input) {
1294
1689
  const print = input.print ?? ((line) => console.log(line));
1295
1690
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
@@ -1297,7 +1692,7 @@ async function runDoctor(input) {
1297
1692
  assertPluginsResolvable(loader, "doctor");
1298
1693
  const rows = [];
1299
1694
  const config = input.loaded.resolved;
1300
- print(style.header(`Holocron doctor — ${config.name}`));
1695
+ print("");
1301
1696
  print(style.dim(` config: ${input.loaded.filepath}`));
1302
1697
  print("");
1303
1698
  for (const failure of loader.loadFailures()) rows.push({
@@ -1332,10 +1727,7 @@ async function runDoctor(input) {
1332
1727
  status: row.status,
1333
1728
  detail: row.message
1334
1729
  }, `doctor: ${row.capability}`);
1335
- const label = `${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`;
1336
- if (row.status === "ok") print(` ${style.success(label)}`);
1337
- else if (row.status === "fail") print(` ${style.fail(label)}`);
1338
- else print(` ${style.dim(`· ${label}`)}`);
1730
+ print(statusRow(row.status, [`${pad(row.capability, 14)} via ${pad(row.provider, 14)}`, row.message]));
1339
1731
  }
1340
1732
  const summary = rows.reduce((acc, r) => {
1341
1733
  acc[r.status] += 1;
@@ -1347,7 +1739,7 @@ async function runDoctor(input) {
1347
1739
  });
1348
1740
  const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`;
1349
1741
  print("");
1350
- print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
1742
+ print(style.header(summaryLine));
1351
1743
  return {
1352
1744
  rows,
1353
1745
  summary
@@ -1367,6 +1759,24 @@ async function smokeCheck(key, provider, impl) {
1367
1759
  return mk(key, provider, "ok", runs.length === 0 ? "no recent runs" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
1368
1760
  }
1369
1761
  case "vault": return mk(key, provider, "ok", `${(await impl.list()).length} keys available`);
1762
+ case "deployment": return mk(key, provider, "ok", `${(await impl.listProjects()).length} project(s) visible`);
1763
+ case "environments": return mk(key, provider, "ok", `${(await impl.listEnvironments()).length} environment(s) configured`);
1764
+ case "dns": {
1765
+ const dns = impl;
1766
+ if (dns.whoami) {
1767
+ const who = await dns.whoami();
1768
+ return mk(key, provider, who.ok ? "ok" : "fail", `${who.zones} zone(s) visible`);
1769
+ }
1770
+ return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
1771
+ }
1772
+ case "workers": {
1773
+ const workers = impl;
1774
+ if (workers.whoami) {
1775
+ const who = await workers.whoami();
1776
+ return mk(key, provider, who.ok ? "ok" : "fail", `${who.scripts} script(s) visible`);
1777
+ }
1778
+ return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
1779
+ }
1370
1780
  case "auth": {
1371
1781
  const desc = await impl.describe();
1372
1782
  return mk(key, provider, "ok", `provider: ${desc.provider}; env keys: ${desc.envKeys.join(", ")}`);
@@ -1384,10 +1794,18 @@ async function smokeCheck(key, provider, impl) {
1384
1794
  }
1385
1795
  return mk(key, provider, "ok", `provider: ${desc.provider}; env keys: ${desc.envKeys.join(", ")}`);
1386
1796
  }
1797
+ case "monitoring": {
1798
+ const monitoring = impl;
1799
+ if (monitoring.whoami) {
1800
+ const who = await monitoring.whoami();
1801
+ return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token verified" : "token check failed");
1802
+ }
1803
+ return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
1804
+ }
1387
1805
  default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
1388
1806
  }
1389
1807
  } catch (err) {
1390
- return mk(key, provider, "fail", err instanceof Error ? err.message : String(err));
1808
+ return mk(key, provider, "fail", errorMessage(err));
1391
1809
  }
1392
1810
  }
1393
1811
  function countResolvedLifecycle(report) {
@@ -1401,13 +1819,10 @@ function mk(capability, provider, status, message) {
1401
1819
  message
1402
1820
  };
1403
1821
  }
1404
- function pad(s, width) {
1405
- return s.length >= width ? s : s + " ".repeat(width - s.length);
1406
- }
1407
1822
  //#endregion
1408
1823
  //#region src/commands/new.ts
1409
1824
  /**
1410
- * `holocron new <type> <name>` — create a GitHub repo from a template and
1825
+ * `holocron new repo <type> <name>` — create a GitHub repo from a template and
1411
1826
  * bootstrap it by replacing all template-slug casing variants with the new
1412
1827
  * project name, then generate a `holocron.config.ts` from the answers given
1413
1828
  * during the interactive wizard.
@@ -1657,7 +2072,7 @@ async function runNew(input) {
1657
2072
  stdio: "inherit"
1658
2073
  });
1659
2074
  } catch (err) {
1660
- throw new NewError(`gh repo create failed: ${err instanceof Error ? err.message : String(err)}`);
2075
+ throw new NewError(`gh repo create failed: ${errorMessage(err)}`);
1661
2076
  }
1662
2077
  if (input.isTemplate) try {
1663
2078
  execFn("gh", [
@@ -1752,7 +2167,7 @@ async function runNew(input) {
1752
2167
  stdio: "inherit"
1753
2168
  });
1754
2169
  } catch (err) {
1755
- print(` ✗ pnpm install failed — ${err instanceof Error ? err.message : String(err)}`);
2170
+ print(` ✗ pnpm install failed — ${errorMessage(err)}`);
1756
2171
  return {
1757
2172
  status: "fail",
1758
2173
  repoDir,
@@ -1761,7 +2176,7 @@ async function runNew(input) {
1761
2176
  };
1762
2177
  }
1763
2178
  print("");
1764
- print(" Running holocron setup…");
2179
+ print(" Running holocron repo setup…");
1765
2180
  try {
1766
2181
  const setupArgs = ["setup"];
1767
2182
  const setupEnv = {};
@@ -1781,7 +2196,7 @@ async function runNew(input) {
1781
2196
  env: setupEnv
1782
2197
  });
1783
2198
  } catch {
1784
- print(" ✗ holocron setup failed — run it manually after checking your config");
2199
+ print(" ✗ holocron repo setup failed — run it manually after checking your config");
1785
2200
  }
1786
2201
  }
1787
2202
  print("");
@@ -1791,7 +2206,7 @@ async function runNew(input) {
1791
2206
  print(` 1. cd ${repoDir}`);
1792
2207
  if (input.noVerify) {
1793
2208
  print(` 2. pnpm install`);
1794
- print(` 3. holocron setup # wire up secrets, teams, labels, etc.`);
2209
+ print(` 3. holocron repo setup # wire up secrets, teams, labels, etc.`);
1795
2210
  print(` 4. git push -u origin HEAD`);
1796
2211
  } else print(` 2. git push -u origin HEAD`);
1797
2212
  logger.info({
@@ -1963,14 +2378,16 @@ describe("resolveToken", () => {
1963
2378
  });
1964
2379
 
1965
2380
  it("throws AuthError with a helpful message when nothing is set", () => {
1966
- try {
1967
- resolveToken({ env: {}, keyring: noKeyring });
1968
- throw new Error("should have thrown");
1969
- } catch (err) {
1970
- expect(err).toBeInstanceOf(AuthError);
1971
- expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
1972
- expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
1973
- }
2381
+ const err = (() => {
2382
+ try {
2383
+ resolveToken({ env: {}, keyring: noKeyring });
2384
+ } catch (e) {
2385
+ return e;
2386
+ }
2387
+ })();
2388
+ expect(err).toBeInstanceOf(AuthError);
2389
+ expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
2390
+ expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
1974
2391
  });
1975
2392
  });
1976
2393
  `;
@@ -2014,24 +2431,24 @@ export class ${inputs.capabilityClass} {
2014
2431
  //#endregion
2015
2432
  //#region src/commands/plugin-create/templates/capability-test.ts
2016
2433
  function render$14(inputs) {
2017
- const clientClass = `${inputs.vendorName}RestClient`;
2018
- return `import { describe, it } from "vitest";
2434
+ const factoryName = `create${inputs.vendorName}RestClient`;
2435
+ return `import { describe, expect, it } from "vitest";
2019
2436
 
2020
2437
  import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
2021
- import { ${clientClass} } from "../rest.js";
2438
+ import { ${factoryName} } from "../rest.js";
2022
2439
  import { stubFetch } from "./helpers.js";
2023
2440
 
2024
2441
  // Stub client used by the constructor smoke test. Real capability
2025
2442
  // tests replace this with per-method stubs once implementations land.
2026
2443
  function makeCapability() {
2027
2444
  const stub = stubFetch([]);
2028
- const rest = new ${clientClass}({ token: "t", fetch: stub.fetch });
2445
+ const rest = ${factoryName}({ token: "t", fetch: stub.fetch });
2029
2446
  return new ${inputs.capabilityClass}(() => rest);
2030
2447
  }
2031
2448
 
2032
2449
  describe("${inputs.capabilityClass}", () => {
2033
2450
  it("constructs with a REST client", () => {
2034
- makeCapability();
2451
+ expect(makeCapability().key).toBe("${inputs.capability}");
2035
2452
  });
2036
2453
 
2037
2454
  // TODO: implement one test per ${inputs.capability} capability method
@@ -2047,14 +2464,11 @@ describe("${inputs.capabilityClass}", () => {
2047
2464
  //#endregion
2048
2465
  //#region src/commands/plugin-create/templates/eslint-config.ts
2049
2466
  function render$13(_inputs) {
2050
- return `import root from "../../eslint.config.js";
2467
+ return `import type { Linter } from "eslint";
2051
2468
 
2052
- export default [
2053
- ...root,
2054
- {
2055
- ignores: ["dist/**", "coverage/**"],
2056
- },
2057
- ];
2469
+ import root from "../../eslint.config.js";
2470
+
2471
+ export default [...root, { ignores: ["dist/**", "coverage/**"] }] satisfies Linter.Config[];
2058
2472
  `;
2059
2473
  }
2060
2474
  //#endregion
@@ -2137,22 +2551,33 @@ describe("AUTH_HINT", () => {
2137
2551
  //#endregion
2138
2552
  //#region src/commands/plugin-create/templates/package-json.ts
2139
2553
  function render$10(inputs) {
2554
+ const keywords = [
2555
+ inputs.capability,
2556
+ "holocron",
2557
+ "plugin",
2558
+ inputs.slug
2559
+ ].sort((a, b) => a.localeCompare(b));
2140
2560
  return JSON.stringify({
2141
2561
  name: `@theholocron/holocron-plugin-${inputs.slug}`,
2142
2562
  version: "2.0.0-alpha.1",
2143
2563
  description: `Holocron plugin for ${inputs.vendorName}. Implements the ${inputs.capability} capability against ${inputs.vendorName}'s REST API, plus exports verifyToken + AUTH_HINT for \`holocron auth\`.`,
2144
- homepage: `https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme`,
2564
+ keywords,
2565
+ homepage: `https://github.com/theholocron/holocron/tree/main/plugins/holocron-plugin-${inputs.slug}#readme`,
2145
2566
  bugs: "https://github.com/theholocron/holocron/issues",
2146
2567
  repository: {
2147
2568
  type: "git",
2148
2569
  url: "git+https://github.com/theholocron/holocron.git",
2149
- directory: `packages/holocron-plugin-${inputs.slug}`
2570
+ directory: `plugins/holocron-plugin-${inputs.slug}`
2150
2571
  },
2151
2572
  license: "MIT",
2152
2573
  author: "Newton Koumantzelis",
2574
+ sideEffects: false,
2153
2575
  type: "module",
2154
- main: "./src/index.ts",
2155
- exports: { ".": "./src/index.ts" },
2576
+ exports: { ".": {
2577
+ types: "./dist/index.d.mts",
2578
+ import: "./dist/index.mjs",
2579
+ default: "./dist/index.mjs"
2580
+ } },
2156
2581
  scripts: {
2157
2582
  "delivery.build": "tsdown",
2158
2583
  "sourceQuality.staticAnalysis": "holocron run sourceQuality.staticAnalysis --",
@@ -2161,36 +2586,33 @@ function render$10(inputs) {
2161
2586
  "test:watch": "vitest",
2162
2587
  validate: "tsx scripts/validate.mjs"
2163
2588
  },
2164
- peerDependencies: { "@theholocron/cli": "workspace:*" },
2589
+ peerDependencies: {
2590
+ "@theholocron/cli": "workspace:*",
2591
+ "@theholocron/misc-utils": "catalog:utils"
2592
+ },
2165
2593
  devDependencies: {
2166
2594
  "@theholocron/cli": "workspace:*",
2167
- "@theholocron/tsconfig": "catalog:",
2168
- "@tsconfig/node-lts": "catalog:",
2595
+ "@theholocron/misc-utils": "catalog:utils",
2596
+ "@theholocron/tsconfig": "catalog:configs",
2597
+ "@types/node": "catalog:",
2169
2598
  "@vitest/coverage-v8": "catalog:",
2170
2599
  eslint: "catalog:",
2171
2600
  globals: "catalog:",
2172
- typescript: "catalog:",
2173
- vitest: "catalog:",
2174
2601
  tsdown: "catalog:",
2175
- tsx: "catalog:"
2176
- },
2177
- publishConfig: {
2178
- access: "public",
2179
- main: "./dist/index.mjs",
2180
- types: "./dist/index.d.mts",
2181
- exports: { ".": {
2182
- types: "./dist/index.d.mts",
2183
- import: "./dist/index.mjs",
2184
- default: "./dist/index.mjs"
2185
- } }
2602
+ tsx: "catalog:",
2603
+ typescript: "catalog:",
2604
+ vitest: "catalog:"
2186
2605
  },
2187
- files: ["dist", "README.md"]
2188
- }, null, " ") + "\n";
2606
+ engines: { node: ">=22" },
2607
+ publishConfig: { access: "public" },
2608
+ files: ["dist"]
2609
+ }, null, 2) + "\n";
2189
2610
  }
2190
2611
  //#endregion
2191
2612
  //#region src/commands/plugin-create/templates/plugin-index.ts
2192
2613
  function render$9(inputs) {
2193
2614
  const clientClass = `${inputs.vendorName}RestClient`;
2615
+ const factoryName = `create${inputs.vendorName}RestClient`;
2194
2616
  const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
2195
2617
  return `/**
2196
2618
  * \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
@@ -2207,7 +2629,7 @@ function render$9(inputs) {
2207
2629
 
2208
2630
  import { resolveToken, type ResolveTokenInput } from "./auth.js";
2209
2631
  import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
2210
- import { ${clientClass} } from "./rest.js";
2632
+ import { ${factoryName}, type ${clientClass} } from "./rest.js";
2211
2633
 
2212
2634
  export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
2213
2635
  /** Override base URL for tests. */
@@ -2228,10 +2650,12 @@ export function createContext(options: ${inputs.vendorName}PluginOptions): Plugi
2228
2650
  options,
2229
2651
  rest: () => {
2230
2652
  if (rest) return rest;
2231
- const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token: resolveToken(options) };
2653
+ const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = {
2654
+ token: resolveToken(options),
2655
+ };
2232
2656
  if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
2233
2657
  if (options.fetch !== undefined) restOpts.fetch = options.fetch;
2234
- return (rest = new ${clientClass}(restOpts));
2658
+ return (rest = ${factoryName}(restOpts));
2235
2659
  },
2236
2660
  };
2237
2661
  }
@@ -2265,10 +2689,10 @@ export const AUTH_HINT =
2265
2689
  // ── Public re-exports ────────────────────────────────────────────────
2266
2690
 
2267
2691
  export * from "./auth.js";
2268
- export { ${clientClass} } from "./rest.js";
2692
+ export { type ${clientClass}, ${factoryName} } from "./rest.js";
2269
2693
  export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
2270
2694
  export { verifyToken } from "./verify-token.js";
2271
- export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
2695
+ export type { VerifyTokenFailure, VerifyTokenResult, VerifyTokenSuccess } from "@theholocron/cli";
2272
2696
  `;
2273
2697
  }
2274
2698
  //#endregion
@@ -2335,22 +2759,33 @@ TODO: fill in as capability methods land.
2335
2759
 
2336
2760
  ## Status
2337
2761
 
2338
- **\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin create\`.
2762
+ **\`v2.0.0-alpha.1\`** — scaffolded via \`holocron new plugin\`.
2339
2763
  Not yet published; capability methods are stubs.
2340
2764
  `;
2341
2765
  }
2342
2766
  //#endregion
2343
2767
  //#region src/commands/plugin-create/templates/rest.ts
2344
2768
  function render$7(inputs) {
2345
- return `import { createRestClient, type RequestOptions, type RestClient } from "@theholocron/cli";
2769
+ const factoryName = `create${inputs.vendorName}RestClient`;
2770
+ const clientType = `${inputs.vendorName}RestClient`;
2771
+ return `import { createRestClient, type RestClient } from "@theholocron/cli";
2772
+
2773
+ export type { RestClient };
2346
2774
 
2347
- export type { RequestOptions, RestClient };
2775
+ /**
2776
+ * TODO: narrow this to the specific ${inputs.vendorName} endpoints this
2777
+ * plugin's capability needs (see \`@theholocron/holocron-plugin-axiom\`'s
2778
+ * \`rest.ts\` for the pattern: an interface with one method per endpoint,
2779
+ * built internally via \`createRestClient\`). Starts as an alias for the
2780
+ * generic client so the scaffold compiles end to end immediately.
2781
+ */
2782
+ export type ${clientType} = RestClient;
2348
2783
 
2349
- export function ${`create${inputs.vendorName}RestClient`}(opts: {
2784
+ export function ${factoryName}(opts: {
2350
2785
  \ttoken: string;
2351
2786
  \tbaseUrl?: string;
2352
2787
  \tfetch?: typeof fetch;
2353
- }): RestClient {
2788
+ }): ${clientType} {
2354
2789
  \treturn createRestClient({
2355
2790
  \t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
2356
2791
  \t\ttoken: opts.token,
@@ -2423,7 +2858,7 @@ describe("${factoryName}", () => {
2423
2858
  function render$5(inputs) {
2424
2859
  return JSON.stringify({
2425
2860
  display: `Holocron Plugin: ${inputs.vendorName}`,
2426
- extends: "@tsconfig/node-lts/tsconfig.json",
2861
+ extends: "@theholocron/tsconfig/node-lts",
2427
2862
  compilerOptions: {
2428
2863
  baseUrl: "./",
2429
2864
  outDir: "./dist",
@@ -2431,7 +2866,7 @@ function render$5(inputs) {
2431
2866
  },
2432
2867
  include: ["src/**/*.ts"],
2433
2868
  exclude: ["node_modules", "dist"]
2434
- }, null, " ") + "\n";
2869
+ }, null, 2) + "\n";
2435
2870
  }
2436
2871
  //#endregion
2437
2872
  //#region src/commands/plugin-create/templates/tsdown-config.ts
@@ -2487,11 +2922,12 @@ function render$3(inputs) {
2487
2922
  * TODO: replace the positional args below with whatever your
2488
2923
  * capability's methods need (e.g., project id, environment slug,
2489
2924
  * secret name). Adapt the test steps to your capability's method
2490
- * surface. Model on \`packages/holocron-plugin-infisical/scripts/validate.mjs\`.
2925
+ * surface. Model on \`plugins/holocron-plugin-infisical/scripts/validate.mjs\`.
2491
2926
  */
2492
2927
 
2493
2928
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
2494
2929
  import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
2930
+ import { errorMessage } from "@theholocron/misc-utils";
2495
2931
 
2496
2932
  const args = process.argv.slice(2);
2497
2933
 
@@ -2511,7 +2947,7 @@ try {
2511
2947
  } catch (err) {
2512
2948
  if (err instanceof AuthError) {
2513
2949
  console.error(err.message);
2514
- console.error(" see: packages/holocron-plugin-${inputs.slug}/README.md#setup");
2950
+ console.error(" see: plugins/holocron-plugin-${inputs.slug}/README.md#setup");
2515
2951
  process.exit(2);
2516
2952
  }
2517
2953
  throw err;
@@ -2547,11 +2983,12 @@ console.log("Done. Fill in capability method calls above before shipping.");
2547
2983
  // ── helpers ────────────────────────────────────────────────────────
2548
2984
 
2549
2985
  /** Wrap a capability call, print ERROR + hint on failure. */
2986
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- called once a real capability check replaces the TODO example above
2550
2987
  async function runStep(body) {
2551
2988
  try {
2552
2989
  await body();
2553
2990
  } catch (err) {
2554
- const message = err instanceof Error ? err.message : String(err);
2991
+ const message = errorMessage(err);
2555
2992
  console.log(\` ✗ ERROR: \${message}\`);
2556
2993
  const hint = hintFor(message);
2557
2994
  if (hint) console.log(\` hint: \${hint}\`);
@@ -2586,7 +3023,7 @@ function hintFor(message) {
2586
3023
  //#endregion
2587
3024
  //#region src/commands/plugin-create/templates/verify-token.ts
2588
3025
  function render$2(inputs) {
2589
- const clientClass = `${inputs.vendorName}RestClient`;
3026
+ const factoryName = `create${inputs.vendorName}RestClient`;
2590
3027
  return `/**
2591
3028
  * \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
2592
3029
  * \`holocron auth check\`. Hits a lightweight whoami-style endpoint
@@ -2603,19 +3040,10 @@ function render$2(inputs) {
2603
3040
  * \`/me\`, \`/account\`.
2604
3041
  */
2605
3042
 
2606
- import { ${clientClass} } from "./rest.js";
2607
-
2608
- export interface VerifyTokenSuccess {
2609
- ok: true;
2610
- subject: string;
2611
- }
2612
-
2613
- export interface VerifyTokenFailure {
2614
- ok: false;
2615
- message: string;
2616
- }
3043
+ import type { VerifyTokenResult } from "@theholocron/cli";
3044
+ import { errorMessage } from "@theholocron/misc-utils";
2617
3045
 
2618
- export type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
3046
+ import { ${factoryName} } from "./rest.js";
2619
3047
 
2620
3048
  interface MeResponse {
2621
3049
  /** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
@@ -2630,18 +3058,17 @@ export interface VerifyTokenOptions {
2630
3058
  }
2631
3059
 
2632
3060
  export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
2633
- const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
3061
+ const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = { token };
2634
3062
  if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
2635
3063
  if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
2636
- const rest = new ${clientClass}(restOpts);
3064
+ const rest = ${factoryName}(restOpts);
2637
3065
  try {
2638
3066
  const me = await rest.request<MeResponse>("/me");
2639
3067
  // Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
2640
3068
  const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
2641
3069
  return { ok: true, subject: \`user @ \${subject}\` };
2642
3070
  } catch (err) {
2643
- const message = err instanceof Error ? err.message : String(err);
2644
- return { ok: false, message };
3071
+ return { ok: false, message: errorMessage(err) };
2645
3072
  }
2646
3073
  }
2647
3074
  `;
@@ -2659,18 +3086,14 @@ describe("verifyToken", () => {
2659
3086
  const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
2660
3087
  const result = await verifyToken("token", { fetch: stub.fetch });
2661
3088
  expect(result.ok).toBe(true);
2662
- if (result.ok) {
2663
- expect(result.subject).toMatch(/user@example.com/);
2664
- }
3089
+ expect((result as { ok: boolean; subject?: string }).subject).toMatch(/user@example.com/);
2665
3090
  });
2666
3091
 
2667
3092
  it("returns ok:false with the error message on 401", async () => {
2668
3093
  const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
2669
3094
  const result = await verifyToken("bad", { fetch: stub.fetch });
2670
3095
  expect(result.ok).toBe(false);
2671
- if (!result.ok) {
2672
- expect(result.message).toMatch(/→ 401/);
2673
- }
3096
+ expect((result as { ok: boolean; message?: string }).message).toMatch(/→ 401/);
2674
3097
  });
2675
3098
 
2676
3099
  it("returns ok:false when the network layer throws", async () => {
@@ -2679,9 +3102,7 @@ describe("verifyToken", () => {
2679
3102
  };
2680
3103
  const result = await verifyToken("t", { fetch: throwing });
2681
3104
  expect(result.ok).toBe(false);
2682
- if (!result.ok) {
2683
- expect(result.message).toMatch(/network down/);
2684
- }
3105
+ expect((result as { ok: boolean; message?: string }).message).toMatch(/network down/);
2685
3106
  });
2686
3107
  });
2687
3108
  `;
@@ -2709,7 +3130,7 @@ export default defineConfig({
2709
3130
  //#endregion
2710
3131
  //#region src/commands/plugin-create/index.ts
2711
3132
  /**
2712
- * `holocron plugin create <slug> <vendor>` — scaffold a new plugin
3133
+ * `holocron new plugin <slug> <vendor>` — scaffold a new plugin
2713
3134
  * package matching the proven template.
2714
3135
  *
2715
3136
  * Design: see `docs/wiki/specifications/tool-plugin-create.spec.md`.
@@ -2717,11 +3138,11 @@ export default defineConfig({
2717
3138
  * Flow:
2718
3139
  * 1. Preflight — verify CWD is a workspace root (pnpm-workspace.yaml
2719
3140
  * + packages/ dir present).
2720
- * 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
3141
+ * 2. Slug collision — plugins/holocron-plugin-<slug>/ must not exist.
2721
3142
  * 3. Capability sanity — must be one of the 14 known keys; warn for
2722
3143
  * many-cardinality caps.
2723
3144
  * 4. Generate — for each template, write to
2724
- * packages/holocron-plugin-<slug>/<path>.
3145
+ * plugins/holocron-plugin-<slug>/<path>.
2725
3146
  * 5. Verify (unless --no-verify) — runs pnpm install +
2726
3147
  * pnpm --filter <pkg> typecheck lint test.
2727
3148
  * 6. Print next steps.
@@ -2755,7 +3176,7 @@ const TEMPLATES = [
2755
3176
  render
2756
3177
  },
2757
3178
  {
2758
- path: "eslint.config.js",
3179
+ path: "eslint.config.ts",
2759
3180
  render: render$13
2760
3181
  },
2761
3182
  {
@@ -2827,7 +3248,7 @@ function runPluginCreate(input) {
2827
3248
  preflight(cwd);
2828
3249
  validateSlug(input.slug);
2829
3250
  validateVendorName(input.vendorName);
2830
- const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
3251
+ const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
2831
3252
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
2832
3253
  validateCapability(input.capability, print);
2833
3254
  const derived = deriveDefaults$1({
@@ -2901,7 +3322,7 @@ function runPluginCreate(input) {
2901
3322
  });
2902
3323
  print(" ✓ scaffold verified");
2903
3324
  } catch (err) {
2904
- const message = err instanceof Error ? err.message : String(err);
3325
+ const message = errorMessage(err);
2905
3326
  print(` ✗ verify failed — ${message}`);
2906
3327
  logger.warn({
2907
3328
  slug: inputs.slug,
@@ -2931,7 +3352,7 @@ function runPluginCreate(input) {
2931
3352
  };
2932
3353
  }
2933
3354
  function preflight(cwd) {
2934
- if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron plugin create\` from the monorepo root.`);
3355
+ if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron new plugin\` from the monorepo root.`);
2935
3356
  if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
2936
3357
  }
2937
3358
  /**
@@ -2988,7 +3409,7 @@ function printNextSteps$1(print, inputs) {
2988
3409
  //#endregion
2989
3410
  //#region src/commands/publish.ts
2990
3411
  /**
2991
- * `holocron publish --initial` — bottles up the chicken-and-egg bootstrap
3412
+ * `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
2992
3413
  * that every new npm-published holocron monorepo hits.
2993
3414
  *
2994
3415
  * npm requires a package to exist before Trusted Publishing can be
@@ -3002,14 +3423,14 @@ function printNextSteps$1(print, inputs) {
3002
3423
  *
3003
3424
  * $ pnpm install --frozen-lockfile
3004
3425
  * $ pnpm build
3005
- * $ pnpm exec tsx packages/cli/src/cli.ts publish --initial
3426
+ * $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
3006
3427
  *
3007
3428
  * `npm login --auth-type=web` runs automatically the first time — no
3008
3429
  * separate manual step. `pnpm install` + `pnpm build` stay outside the
3009
3430
  * command (no pnpm-inside-pnpm).
3010
3431
  *
3011
3432
  * `--initial` is required today — this command only implements the
3012
- * bootstrap publish. A non-initial `holocron publish` (for a manual publish
3433
+ * bootstrap publish. A non-initial `holocron package publish` (for a manual publish
3013
3434
  * outside the semantic-release/OIDC steady state) isn't built yet; the flag
3014
3435
  * exists so the surface doesn't need another rename when it is.
3015
3436
  *
@@ -3018,7 +3439,7 @@ function printNextSteps$1(print, inputs) {
3018
3439
  * setup` for the ephemeral GH admin PAT.
3019
3440
  */
3020
3441
  /**
3021
- * `holocron publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
3442
+ * `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
3022
3443
  * Trusted Publisher (OIDC) config to a new workflow filename across every
3023
3444
  * public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
3024
3445
  * shell-script prototype this supersedes) for the full "why": npm ties
@@ -3058,7 +3479,7 @@ async function runSyncTrust(input) {
3058
3479
  packages: packageNames.length
3059
3480
  }, "sync-trust: start");
3060
3481
  if (packageNames.length === 0) {
3061
- const message = "nothing to sync (no packages/* and root package.json is private or unnamed)";
3482
+ const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
3062
3483
  print(` ✗ ${message}`);
3063
3484
  return {
3064
3485
  status: "fail",
@@ -3215,7 +3636,7 @@ async function runPublish(input = {}) {
3215
3636
  const exec = input.exec ?? defaultExec$1;
3216
3637
  const login = input.login ?? defaultLogin;
3217
3638
  const publishArgs = [
3218
- ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3639
+ ...workspaceFilterArgs(cwd),
3219
3640
  "publish",
3220
3641
  "--access",
3221
3642
  "public",
@@ -3266,7 +3687,7 @@ async function runPublish(input = {}) {
3266
3687
  const packageNames = input.packages ?? discoverPublicPackages(cwd);
3267
3688
  const repoName = input.repoName ?? await resolveRepoName(cwd, exec);
3268
3689
  if (packageNames.length === 0) {
3269
- const message = "nothing to publish (no packages/* and root package.json is private or unnamed)";
3690
+ const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
3270
3691
  print(` ✗ ${message}`);
3271
3692
  logger.warn({ reason: message }, "publish --initial: done");
3272
3693
  return {
@@ -3300,7 +3721,7 @@ async function runPublish(input = {}) {
3300
3721
  if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
3301
3722
  print("");
3302
3723
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
3303
- print(` pnpm exec tsx packages/cli/src/cli.ts publish --initial --otp <6-digit-code>`);
3724
+ print(` pnpm exec tsx packages/cli/src/cli.ts package publish --initial --otp <6-digit-code>`);
3304
3725
  }
3305
3726
  logger.warn({
3306
3727
  tag,
@@ -3325,7 +3746,7 @@ async function runPublish(input = {}) {
3325
3746
  };
3326
3747
  }
3327
3748
  /**
3328
- * `holocron publish` (no `--initial`) — the steady-state publish
3749
+ * `holocron package publish` (no `--initial`) — the steady-state publish
3329
3750
  * `exec.publishCmd` in `release.config.ts` should call, replacing each
3330
3751
  * repo's hand-typed `pnpm -r --filter=./packages/* publish ...` shell (which
3331
3752
  * differs, today, only in monorepo-vs-single-package, provenance on/off, and
@@ -3336,7 +3757,7 @@ async function runPublish(input = {}) {
3336
3757
  * Deliberately does not do the `--initial` bootstrap dance (npm
3337
3758
  * login-if-needed, Trusted Publisher next-steps printout) — a steady-state
3338
3759
  * publish runs in CI, authenticated via OIDC automatically, no interactive
3339
- * step involved. Reuses `hasPackagesDir()`'s monorepo-vs-single-package
3760
+ * step involved. Reuses `workspaceFilterArgs()`'s monorepo-vs-single-package
3340
3761
  * detection rather than duplicating it.
3341
3762
  */
3342
3763
  async function runSteadyPublish(input = {}) {
@@ -3365,7 +3786,7 @@ async function runSteadyPublish(input = {}) {
3365
3786
  if (otp) print(` otp: <${otp.length} chars>`);
3366
3787
  print("");
3367
3788
  if (entries.length === 0) {
3368
- const message = "nothing to publish (no packages/* and root package.json is private or unnamed)";
3789
+ const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
3369
3790
  print(` ✗ ${message}`);
3370
3791
  logger.warn({ reason: message }, "publish: done");
3371
3792
  return {
@@ -3388,7 +3809,7 @@ async function runSteadyPublish(input = {}) {
3388
3809
  if (skipAlreadyPublished) for (const entry of entries) print(` … would check ${entry.name}@${entry.version}, publish if not already on npm`);
3389
3810
  else {
3390
3811
  const args = [
3391
- ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3812
+ ...workspaceFilterArgs(cwd),
3392
3813
  "publish",
3393
3814
  ...baseFlags,
3394
3815
  ...otpFlags
@@ -3454,7 +3875,7 @@ async function runSteadyPublish(input = {}) {
3454
3875
  }
3455
3876
  print(" → publishing all public @theholocron/* packages…");
3456
3877
  const publish = await exec("pnpm", [
3457
- ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3878
+ ...workspaceFilterArgs(cwd),
3458
3879
  "publish",
3459
3880
  ...baseFlags,
3460
3881
  ...otpFlags
@@ -3465,7 +3886,7 @@ async function runSteadyPublish(input = {}) {
3465
3886
  if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
3466
3887
  print("");
3467
3888
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
3468
- print(` pnpm exec holocron publish --otp <6-digit-code>`);
3889
+ print(` pnpm exec holocron package publish --otp <6-digit-code>`);
3469
3890
  }
3470
3891
  logger.warn({
3471
3892
  tag,
@@ -3491,6 +3912,22 @@ async function runSteadyPublish(input = {}) {
3491
3912
  function hasPackagesDir(cwd) {
3492
3913
  return existsSync(join(cwd, "packages"));
3493
3914
  }
3915
+ /** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
3916
+ function hasPluginsDir(cwd) {
3917
+ return existsSync(join(cwd, "plugins"));
3918
+ }
3919
+ /**
3920
+ * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
3921
+ * roots exist. Empty (single-package repo, no `-r`) when neither does.
3922
+ * Shared by every `pnpm publish` invocation below so a repo with only
3923
+ * `plugins/*` (or both) is covered the same as `packages/*`-only.
3924
+ */
3925
+ function workspaceFilterArgs(cwd) {
3926
+ const filters = [];
3927
+ if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
3928
+ if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
3929
+ return filters.length > 0 ? ["-r", ...filters] : [];
3930
+ }
3494
3931
  /** Read a `package.json`'s `name`, when it's public (`!private && name`). */
3495
3932
  function publicPackageName(pkgPath) {
3496
3933
  if (!existsSync(pkgPath)) return void 0;
@@ -3522,29 +3959,38 @@ function publicPackageEntry(pkgPath, dir) {
3522
3959
  * and publish one workspace at a time.
3523
3960
  */
3524
3961
  function discoverPublicPackageEntries(cwd) {
3525
- if (!hasPackagesDir(cwd)) {
3962
+ if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
3526
3963
  const entry = publicPackageEntry(join(cwd, "package.json"), ".");
3527
3964
  return entry ? [entry] : [];
3528
3965
  }
3529
- const packagesDir = join(cwd, "packages");
3530
- return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3531
- const entry = publicPackageEntry(join(packagesDir, e.name, "package.json"), `./packages/${e.name}`);
3966
+ return [...entriesInWorkspaceDir(cwd, "packages"), ...entriesInWorkspaceDir(cwd, "plugins")];
3967
+ }
3968
+ function entriesInWorkspaceDir(cwd, dirName) {
3969
+ const dirPath = join(cwd, dirName);
3970
+ if (!existsSync(dirPath)) return [];
3971
+ return readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3972
+ const entry = publicPackageEntry(join(dirPath, e.name, "package.json"), `./${dirName}/${e.name}`);
3532
3973
  return entry ? [entry] : [];
3533
3974
  });
3534
3975
  }
3535
3976
  /**
3536
- * Monorepo (`packages/` present): every public `packages/*` workspace.
3537
- * Single package (no `packages/`): the root `package.json`, if it's public —
3538
- * most `node-template` scaffolds are one package at the repo root.
3977
+ * Monorepo (`packages/` and/or `plugins/` present): every public workspace
3978
+ * under either. Single package (neither dir present): the root
3979
+ * `package.json`, if it's public — most `node-template` scaffolds are one
3980
+ * package at the repo root.
3539
3981
  */
3540
3982
  function discoverPublicPackages(cwd) {
3541
- if (!hasPackagesDir(cwd)) {
3983
+ if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
3542
3984
  const name = publicPackageName(join(cwd, "package.json"));
3543
3985
  return name ? [name] : [];
3544
3986
  }
3545
- const packagesDir = join(cwd, "packages");
3546
- return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3547
- const name = publicPackageName(join(packagesDir, e.name, "package.json"));
3987
+ return [...namesInWorkspaceDir(cwd, "packages"), ...namesInWorkspaceDir(cwd, "plugins")];
3988
+ }
3989
+ function namesInWorkspaceDir(cwd, dirName) {
3990
+ const dirPath = join(cwd, dirName);
3991
+ if (!existsSync(dirPath)) return [];
3992
+ return readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3993
+ const name = publicPackageName(join(dirPath, e.name, "package.json"));
3548
3994
  return name ? [name] : [];
3549
3995
  });
3550
3996
  }
@@ -3598,12 +4044,30 @@ const defaultLogin = async (cwd) => {
3598
4044
  };
3599
4045
  //#endregion
3600
4046
  //#region src/commands/secret-set.ts
4047
+ /**
4048
+ * `holocron secrets set` — one-shot single-secret push.
4049
+ *
4050
+ * Complements `holocron secrets sync` (bulk vault → destinations) for
4051
+ * ad-hoc scenarios:
4052
+ * - Bootstrap secrets (e.g., NPM_TOKEN on theholocron/holocron itself
4053
+ * before the rest of the setup exists)
4054
+ * - One-off rotations that don't warrant a vault sync
4055
+ * - Setting secrets the vault doesn't track
4056
+ *
4057
+ * Value source resolution (in priority order):
4058
+ * 1. Positional `value` argument
4059
+ * 2. `--from-stdin` reads from stdin
4060
+ * 3. `--from-env <NAME>` reads from the named env var
4061
+ * 4. Implicit: env var matching the secret name (e.g., `NPM_TOKEN`)
4062
+ *
4063
+ * Errors clearly when no value can be sourced.
4064
+ */
3601
4065
  async function runSecretSet(input) {
3602
4066
  const print = input.print ?? ((line) => console.log(line));
3603
4067
  const logger = input.logger ?? getLogger();
3604
4068
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3605
4069
  await loader.load();
3606
- assertPluginsResolvable(loader, "secret set");
4070
+ assertPluginsResolvable(loader, "secrets set");
3607
4071
  const dryRun = input.context.dryRun ?? false;
3608
4072
  const scope = input.scope ?? { kind: "repo" };
3609
4073
  if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
@@ -3636,7 +4100,7 @@ async function runSecretSet(input) {
3636
4100
  scope
3637
4101
  };
3638
4102
  } catch (err) {
3639
- const message = err instanceof Error ? err.message : String(err);
4103
+ const message = errorMessage(err);
3640
4104
  print(` ✗ ${message}`);
3641
4105
  logger.warn({
3642
4106
  key: input.name,
@@ -3672,6 +4136,38 @@ function describeScope(scope) {
3672
4136
  }
3673
4137
  //#endregion
3674
4138
  //#region src/commands/secrets-sync.ts
4139
+ /**
4140
+ * `holocron secrets sync` — vault → destinations.
4141
+ *
4142
+ * Reads a named environment from the configured `vault` (1P
4143
+ * Environment / similar) as KEY=VALUE pairs, then fans those values
4144
+ * out to the configured destinations:
4145
+ *
4146
+ * - `secrets` capability (e.g., GH Actions secrets) — repo-scope
4147
+ * - `deployment` capability (e.g., Vercel env vars) — per-target
4148
+ * - local `.env` file (optional, planned for v5.1 — currently skipped
4149
+ * with a soft "not yet" note)
4150
+ *
4151
+ * Per-secret routing rules:
4152
+ *
4153
+ * - All keys go to `deployment` for each target the caller asks for
4154
+ * (defaults: production + preview)
4155
+ * - `secrets` (GH Actions) defaults to every key, repo-scoped — but a
4156
+ * caller can pass `githubSecretKeys` (an allowlist, from repeatable
4157
+ * `--github-secret KEY` CLI values) to restrict `secrets` to only
4158
+ * those keys, and `githubSecretScope` (same `{ kind }` shape `secret
4159
+ * set`'s own `--scope repo|env=NAME|org=NAME` parses to) to apply one
4160
+ * scope to all of them. A key not in the allowlist is skipped for
4161
+ * `secrets` entirely — still synced to `deployment` if configured.
4162
+ * Needed for a vault holding a mix of deploy-only secrets and one
4163
+ * CI-consumed, org-wide secret (holocron#781's Sentinel case: 4
4164
+ * Vercel-only keys + one org-scoped Axiom ingest token
4165
+ * `theholocron/.github`'s dispatched-check workflow also reads).
4166
+ *
4167
+ * The vault MUST implement `readEnvironment(envId)` (optional method).
4168
+ * If it doesn't, the command errors with a hint to populate the keys
4169
+ * individually via `holocron secrets set` (also planned for v5.1).
4170
+ */
3675
4171
  async function runSecretsSync(input) {
3676
4172
  const print = input.print ?? ((line) => console.log(line));
3677
4173
  const logger = input.logger ?? getLogger();
@@ -3696,10 +4192,13 @@ async function runSecretsSync(input) {
3696
4192
  const rows = [];
3697
4193
  if (loader.has("secrets")) {
3698
4194
  const secrets = loader.get("secrets");
3699
- print(style.step("secrets (repo scope)"));
4195
+ const scope = input.githubSecretScope ?? { kind: "repo" };
4196
+ const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
4197
+ print(style.step("secrets"));
3700
4198
  for (const key of keys) {
3701
- rows.push(await runRow(`secrets:${secrets.providerName}`, "scope=repo", key, dryRun, async () => {
3702
- await secrets.setSecret({ kind: "repo" }, key, envVars[key]);
4199
+ if (allowedKeys && !allowedKeys.has(key)) continue;
4200
+ rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
4201
+ await secrets.setSecret(scope, key, envVars[key]);
3703
4202
  }));
3704
4203
  print(formatRow(rows[rows.length - 1]));
3705
4204
  }
@@ -3779,7 +4278,7 @@ async function runRow(destination, scope, key, dryRun, body) {
3779
4278
  scope,
3780
4279
  key,
3781
4280
  status: "fail",
3782
- message: err instanceof Error ? err.message : String(err)
4281
+ message: errorMessage(err)
3783
4282
  };
3784
4283
  }
3785
4284
  }
@@ -3791,6 +4290,11 @@ function formatRow(row) {
3791
4290
  if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3792
4291
  return ` ${style.dim(`· ${label}`)}`;
3793
4292
  }
4293
+ function scopeLabel(scope) {
4294
+ if (scope.kind === "repo") return "scope=repo";
4295
+ if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
4296
+ return `scope=environment,env=${scope.name}`;
4297
+ }
3794
4298
  function vaultProviderName(loader) {
3795
4299
  if (!loader.has("vault")) return "<missing>";
3796
4300
  return loader.get("vault").providerName;
@@ -3867,7 +4371,7 @@ function deriveCapabilities(providers) {
3867
4371
  return Object.keys(providers ?? {}).sort();
3868
4372
  }
3869
4373
  /**
3870
- * `source` + `ci` are the minimal baseline every repo `holocron setup`
4374
+ * `source` + `ci` are the minimal baseline every repo `holocron repo setup`
3871
4375
  * touches wires — every `holocron.config.ts` in this org already satisfies
3872
4376
  * this, so `non-compliant` here means drift (a provider manually removed
3873
4377
  * after setup), not an unmet aspirational policy. Deliberately not a richer
@@ -3995,7 +4499,7 @@ var config_default = "# Configuration for sentiment-bot - https://github.com/beh
3995
4499
  //#endregion
3996
4500
  //#region src/utils/create-header.ts
3997
4501
  function createHeader(options) {
3998
- const { source, tool = "holocron setup", forPrimary = false } = options;
4502
+ const { source, tool = "holocron repo setup", forPrimary = false } = options;
3999
4503
  const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
4000
4504
  return {
4001
4505
  workflowHeader(format = "yaml") {
@@ -4019,7 +4523,7 @@ function createHeader(options) {
4019
4523
  },
4020
4524
  scaffoldHeader() {
4021
4525
  return [
4022
- `# Scaffolded by holocron setup — edit this file freely.`,
4526
+ `# Scaffolded by holocron repo setup — edit this file freely.`,
4023
4527
  `# Source: theholocron/holocron · ${source}`,
4024
4528
  ``
4025
4529
  ].join("\n");
@@ -4033,6 +4537,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
4033
4537
  //#region src/templates/configs/alexjs/alexrc.json
4034
4538
  var alexrc_default = { allow: [
4035
4539
  "dead",
4540
+ "execute",
4541
+ "execution",
4542
+ "executes",
4036
4543
  "failure",
4037
4544
  "failures",
4038
4545
  "hook",
@@ -4051,12 +4558,21 @@ function createIgnoreConfig$1() {
4051
4558
  }
4052
4559
  //#endregion
4053
4560
  //#region src/templates/configs/commit-msg/commit-msg
4054
- var commit_msg_default = "# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to commitlint's own auto-discovery of a local commitlint.config.ts when\n# the shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nCOMMITLINT_CONFIG=\"node_modules/@theholocron/commitlint-config/dist/index.js\"\nif [ -f \"$COMMITLINT_CONFIG\" ]; then\n npx --no -- commitlint --config \"$COMMITLINT_CONFIG\" --edit \"$1\"\nelse\n npx --no -- commitlint --edit \"$1\"\nfi\n";
4561
+ var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
4055
4562
  //#endregion
4056
4563
  //#region src/templates/configs/commit-msg/create-config.ts
4057
4564
  const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4058
- function createConfig$7() {
4059
- return `${workflowHeader$8("shebang")}${commit_msg_default}`;
4565
+ /**
4566
+ * `.husky/commit-msg` — lints the commit message via `holocron run lint
4567
+ * commit-msg` (holocron#789), the real programmatic replacement for
4568
+ * shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
4569
+ * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4570
+ *
4571
+ * @param holocronScript - the command that invokes the CLI. Defaults to
4572
+ * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4573
+ */
4574
+ function createConfig$7(holocronScript = "pnpm exec holocron") {
4575
+ return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4060
4576
  }
4061
4577
  //#endregion
4062
4578
  //#region src/templates/configs/devmoji/create-config.ts
@@ -4072,7 +4588,7 @@ function createConfig$6() {
4072
4588
  }
4073
4589
  //#endregion
4074
4590
  //#region src/templates/configs/editorconfig/editorconfig
4075
- var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
4591
+ var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\nindent_style = unset\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
4076
4592
  //#endregion
4077
4593
  //#region src/templates/configs/editorconfig/create-config.ts
4078
4594
  const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
@@ -4129,7 +4645,7 @@ var pre_commit_default = "# Run GitLeaks against staged changes only — a full
4129
4645
  const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
4130
4646
  /**
4131
4647
  * `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
4132
- * lint-staged before every commit. Installed by `holocron setup` when hooks
4648
+ * lint-staged before every commit. Installed by `holocron repo setup` when hooks
4133
4649
  * are enabled (default for `protection: "strict"`). `git commit --no-verify`
4134
4650
  * bypasses it.
4135
4651
  *
@@ -4144,13 +4660,13 @@ function createConfig$3() {
4144
4660
  }
4145
4661
  //#endregion
4146
4662
  //#region src/templates/configs/pre-push/pre-push
4147
- var pre_push_default = "# Run the merge-gating checks locally before pushing — the same set CI runs.\n# Bypass once with: git push --no-verify\n\n# CI runners (semantic-release, super-linter auto-commit, bots) push without a\n# built dev environment — the hook has nothing to add there.\n[ -n \"$CI\" ] && exit 0\n\n__HOLOCRON_SCRIPT__ ci\n";
4663
+ var pre_push_default = "# Run the merge-gating checks locally before pushing — the same set CI runs.\n# Bypass once with: git push --no-verify\n\n# CI runners (semantic-release, super-linter auto-commit, bots) push without a\n# built dev environment — the hook has nothing to add there.\n[ -n \"$CI\" ] && exit 0\n\n__HOLOCRON_SCRIPT__ run ci\n";
4148
4664
  //#endregion
4149
4665
  //#region src/templates/configs/pre-push/create-config.ts
4150
4666
  const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
4151
4667
  /**
4152
- * `.husky/pre-push` — runs `holocron ci` (the merge-gating checks, in CI order)
4153
- * before every push. Installed by `holocron setup` when hooks are enabled
4668
+ * `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
4669
+ * before every push. Installed by `holocron repo setup` when hooks are enabled
4154
4670
  * (default for `protection: "strict"`). `git push --no-verify` bypasses it.
4155
4671
  *
4156
4672
  * @param holocronScript - the command that invokes the CLI. Defaults to
@@ -4197,7 +4713,7 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
4197
4713
  /**
4198
4714
  * Canonical AI engineering workflow role prompts.
4199
4715
  *
4200
- * Written to `.agents/prompts/<role>.md` by `holocron setup` when `agent` is
4716
+ * Written to `.agents/prompts/<role>.md` by `holocron repo setup` when `agent` is
4201
4717
  * configured. Paths are gitignored and regenerated on every setup run so the
4202
4718
  * content always reflects the current CLI version.
4203
4719
  *
@@ -4695,7 +5211,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
4695
5211
  capability: "source",
4696
5212
  step,
4697
5213
  status: "fail",
4698
- message: err instanceof Error ? err.message : String(err)
5214
+ message: errorMessage(err)
4699
5215
  };
4700
5216
  }
4701
5217
  try {
@@ -4718,7 +5234,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
4718
5234
  capability: "source",
4719
5235
  step,
4720
5236
  status: "fail",
4721
- message: err instanceof Error ? err.message : String(err)
5237
+ message: errorMessage(err)
4722
5238
  };
4723
5239
  }
4724
5240
  }
@@ -4811,7 +5327,7 @@ async function execStep(capability, step, dryRun, body, opts = {}) {
4811
5327
  capability,
4812
5328
  step,
4813
5329
  status: "fail",
4814
- message: err instanceof Error ? err.message : String(err)
5330
+ message: errorMessage(err)
4815
5331
  };
4816
5332
  }
4817
5333
  }
@@ -4984,7 +5500,7 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
4984
5500
  //#endregion
4985
5501
  //#region src/commands/setup/run-setup.ts
4986
5502
  /**
4987
- * `holocron setup` — orchestrates per-capability setup actions across
5503
+ * `holocron repo setup` — orchestrates per-capability setup actions across
4988
5504
  * every plugin loaded from `holocron.config.json`.
4989
5505
  *
4990
5506
  * Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
@@ -5004,18 +5520,18 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
5004
5520
  */
5005
5521
  const { workflowHeader: workflowHeader$1 } = createHeader({
5006
5522
  source: "packages/cli/src/commands/setup/run-setup.ts",
5007
- tool: "holocron setup"
5523
+ tool: "holocron repo setup"
5008
5524
  });
5009
5525
  const { workflowHeader: thinCallerHeader } = createHeader({
5010
5526
  source: "packages/astromech/src/thin-callers.ts",
5011
- tool: "holocron setup"
5527
+ tool: "holocron repo setup"
5012
5528
  });
5013
5529
  async function runSetup(input) {
5014
5530
  const print = input.print ?? ((line) => console.log(line));
5015
5531
  const logger = getLogger();
5016
5532
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
5017
5533
  await withSpinner("Loading plugins…", () => loader.load());
5018
- assertPluginsResolvable(loader, "setup");
5534
+ assertPluginsResolvable(loader, "repo setup");
5019
5535
  const config = input.loaded.resolved;
5020
5536
  const dryRun = input.context.dryRun ?? false;
5021
5537
  const steps = [];
@@ -5052,7 +5568,10 @@ async function runSetup(input) {
5052
5568
  print(formatStep(steps[steps.length - 1]));
5053
5569
  if (effectivePreset && effectivePreset !== "none") {
5054
5570
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
5055
- await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
5571
+ await source.updateRepoSettings({
5572
+ ...BALANCED_REPO_SETTINGS,
5573
+ ...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
5574
+ });
5056
5575
  }));
5057
5576
  print(formatStep(steps[steps.length - 1]));
5058
5577
  const tasksConfig = { tasks: config.tasks };
@@ -5159,13 +5678,13 @@ async function runSetup(input) {
5159
5678
  }));
5160
5679
  print(formatStep(steps[steps.length - 1]));
5161
5680
  steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5162
- await source.writeRepoFile(".husky/commit-msg", createConfig$7());
5681
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5163
5682
  }));
5164
5683
  print(formatStep(steps[steps.length - 1]));
5165
5684
  if (hooksEnabled) {
5166
5685
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5167
5686
  await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
5168
- return "runs `holocron ci` before push — bypass with `git push --no-verify`";
5687
+ return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
5169
5688
  }));
5170
5689
  print(formatStep(steps[steps.length - 1]));
5171
5690
  steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
@@ -5233,13 +5752,20 @@ async function runSetup(input) {
5233
5752
  await source.writeRepoFile("turbo.json", content);
5234
5753
  }));
5235
5754
  print(formatStep(steps[steps.length - 1]));
5236
- const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5755
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5756
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5237
5757
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5238
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5758
+ const rootScripts = rootPkg?.scripts ?? {};
5239
5759
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5240
5760
  if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5241
5761
  await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5242
5762
  }));
5763
+ if (rootPkgRaw) {
5764
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5765
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5766
+ await source.writeRepoFile("package.json", depFix.content);
5767
+ }));
5768
+ }
5243
5769
  }
5244
5770
  print(formatStep(steps[steps.length - 1]));
5245
5771
  }
@@ -5291,34 +5817,36 @@ async function runSetup(input) {
5291
5817
  print(formatStep(steps[steps.length - 1]));
5292
5818
  }
5293
5819
  const teams = repo?.teams ?? [];
5294
- if (teams.length > 0) if (source.syncTeams) {
5295
- steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5296
- print(formatStep(steps[steps.length - 1]));
5297
- const repoCoord = input.context.repo ?? repo?.name ?? "";
5298
- const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5299
- const writeableTeams = teams.map((t) => typeof t === "string" ? {
5300
- slug: t,
5301
- permission: "push"
5302
- } : t).filter((t) => [
5303
- "push",
5304
- "maintain",
5305
- "admin"
5306
- ].includes(t.permission));
5307
- if (org && writeableTeams.length > 0) {
5308
- steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5309
- const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5310
- await source.writeRepoFile(".github/CODEOWNERS", content);
5311
- }));
5820
+ if (teams.length > 0) {
5821
+ if (source.syncTeams) {
5822
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5823
+ print(formatStep(steps[steps.length - 1]));
5824
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
5825
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5826
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
5827
+ slug: t,
5828
+ permission: "push"
5829
+ } : t).filter((t) => [
5830
+ "push",
5831
+ "maintain",
5832
+ "admin"
5833
+ ].includes(t.permission));
5834
+ if (org && writeableTeams.length > 0) {
5835
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5836
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5837
+ await source.writeRepoFile(".github/CODEOWNERS", content);
5838
+ }));
5839
+ print(formatStep(steps[steps.length - 1]));
5840
+ }
5841
+ } else {
5842
+ steps.push({
5843
+ capability: "source",
5844
+ step: "sync teams",
5845
+ status: "skip",
5846
+ message: "provider does not implement syncTeams"
5847
+ });
5312
5848
  print(formatStep(steps[steps.length - 1]));
5313
5849
  }
5314
- } else {
5315
- steps.push({
5316
- capability: "source",
5317
- step: "sync teams",
5318
- status: "skip",
5319
- message: "provider does not implement syncTeams"
5320
- });
5321
- print(formatStep(steps[steps.length - 1]));
5322
5850
  }
5323
5851
  }
5324
5852
  if (loader.has("environments")) {
@@ -5374,11 +5902,9 @@ async function runSetup(input) {
5374
5902
  const wikiProxy = wiki.proxyConfig?.();
5375
5903
  if (wikiDns && loader.has("dns")) {
5376
5904
  const dns = loader.get("dns");
5377
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5905
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5378
5906
  await dns.upsertRecord(wikiDns.zone, {
5379
- type: "CNAME",
5380
- name: wikiDns.cname,
5381
- content: wikiDns.target,
5907
+ ...wikiDns.record,
5382
5908
  ttl: 1,
5383
5909
  ...wikiProxy ? { proxied: true } : {}
5384
5910
  });
@@ -5387,8 +5913,8 @@ async function runSetup(input) {
5387
5913
  }
5388
5914
  if (wikiProxy && wikiDns && loader.has("workers")) {
5389
5915
  const workers = loader.get("workers");
5390
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5391
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5916
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5917
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5392
5918
  }));
5393
5919
  print(formatStep(steps[steps.length - 1]));
5394
5920
  }
@@ -5401,11 +5927,31 @@ async function runSetup(input) {
5401
5927
  org: config.org,
5402
5928
  domain: config.domain
5403
5929
  }) : null;
5930
+ const ensureCustomDomain = async (projectId, hostname) => {
5931
+ if (!deploy.ensureCustomDomain) return;
5932
+ const customDomainDns = { value: null };
5933
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5934
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5935
+ }));
5936
+ print(formatStep(steps[steps.length - 1]));
5937
+ if (customDomainDns.value && loader.has("dns")) {
5938
+ const dns = loader.get("dns");
5939
+ const dnsRecord = customDomainDns.value;
5940
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5941
+ await dns.upsertRecord(dnsRecord.zone, {
5942
+ ...dnsRecord.record,
5943
+ ttl: 1
5944
+ });
5945
+ }));
5946
+ print(formatStep(steps[steps.length - 1]));
5947
+ }
5948
+ };
5404
5949
  if (!previewCfg) {
5405
5950
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5406
5951
  await deploy.ensureProject({ name: config.name });
5407
5952
  }));
5408
5953
  print(formatStep(steps[steps.length - 1]));
5954
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5409
5955
  }
5410
5956
  if (previewCfg) {
5411
5957
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5413,13 +5959,8 @@ async function runSetup(input) {
5413
5959
  }));
5414
5960
  print(formatStep(steps[steps.length - 1]));
5415
5961
  }
5416
- if (previewCfg?.domain && deploy.ensureCustomDomain) {
5417
- steps.push(await runStep("deployment", `ensureCustomDomain ${previewCfg.domain}`, dryRun, async () => {
5418
- await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
5419
- }));
5420
- print(formatStep(steps[steps.length - 1]));
5421
- }
5422
- if (previewCfg?.domain && loader.has("dns")) {
5962
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5963
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5423
5964
  const dns = loader.get("dns");
5424
5965
  const wildcardDomain = `*.${previewCfg.domain}`;
5425
5966
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5493,7 +6034,7 @@ async function runSetup(input) {
5493
6034
  capability: "vault",
5494
6035
  step: "list",
5495
6036
  status: "fail",
5496
- message: err instanceof Error ? err.message : String(err)
6037
+ message: errorMessage(err)
5497
6038
  });
5498
6039
  }
5499
6040
  print(formatStep(steps[steps.length - 1]));
@@ -5532,6 +6073,17 @@ async function runSetup(input) {
5532
6073
  print(formatStep(steps[steps.length - 1]));
5533
6074
  }
5534
6075
  }
6076
+ if (loader.has("monitoring")) {
6077
+ const monitoring = loader.get("monitoring");
6078
+ print(style.step("monitoring"));
6079
+ for (const checkInput of monitoring.checks) {
6080
+ steps.push(await runStep("monitoring", `ensureCheck ${checkInput.name}`, dryRun, async () => {
6081
+ const result = await monitoring.ensureCheck(checkInput);
6082
+ return `${checkInput.name} ${result.alreadyExists ? "exists" : "created"}`;
6083
+ }));
6084
+ print(formatStep(steps[steps.length - 1]));
6085
+ }
6086
+ }
5535
6087
  if (loader.has("tooling")) {
5536
6088
  const tools = loader.get("tooling");
5537
6089
  print(style.step("tooling"));
@@ -5618,7 +6170,7 @@ async function runSetup(input) {
5618
6170
  print(style.hint(" · Metadata — read (repository permission, auto-included)"));
5619
6171
  print("");
5620
6172
  print(style.hint(" Create tokens at: https://github.com/settings/personal-access-tokens/new"));
5621
- print(style.hint(" Then re-run: holocron setup --token <your-admin-pat>"));
6173
+ print(style.hint(" Then re-run: holocron repo setup --token <your-admin-pat>"));
5622
6174
  print(style.hint(" Store org token: HOLOCRON_ORG_TOKEN env var or keyring key github.org"));
5623
6175
  }
5624
6176
  return {
@@ -5656,7 +6208,7 @@ async function ensurePrepareScript(repoRoot) {
5656
6208
  /**
5657
6209
  * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
5658
6210
  *
5659
- * Unlike `holocron setup`, this command is purely local (no GitHub token
6211
+ * Unlike `holocron repo setup`, this command is purely local (no GitHub token
5660
6212
  * required). It reads `agent` and `skills` from the config and installs
5661
6213
  * the listed skills into `.agents/skills/<name>/` with a symlink at the
5662
6214
  * agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
@@ -5709,7 +6261,7 @@ async function runSkillsInstall(input) {
5709
6261
  status: "ok"
5710
6262
  }, "skills install: done");
5711
6263
  } catch (err) {
5712
- const message = err instanceof Error ? err.message : String(err);
6264
+ const message = errorMessage(err);
5713
6265
  print(` ✗ ${message}`);
5714
6266
  logger.warn({
5715
6267
  agent: config.agent,
@@ -5867,11 +6419,13 @@ async function runSyncReadme(input) {
5867
6419
  }
5868
6420
  const description = loaded.resolved.description ?? "";
5869
6421
  const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
6422
+ const scripts = pkg.scripts ?? {};
6423
+ const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
5870
6424
  const sections = generateReadme({
5871
6425
  description,
5872
6426
  homepage,
5873
- scripts: pkg.scripts ?? {},
5874
- packages: pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0
6427
+ scripts,
6428
+ packages
5875
6429
  });
5876
6430
  const markerSections = {
5877
6431
  ...sections.packages !== void 0 && { packages: sections.packages },
@@ -6113,7 +6667,7 @@ async function runSyncWiki(input) {
6113
6667
  capability: "local",
6114
6668
  step: "sync wiki",
6115
6669
  status: "fail",
6116
- message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6670
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6117
6671
  };
6118
6672
  }
6119
6673
  }
@@ -6121,7 +6675,7 @@ async function runSyncWiki(input) {
6121
6675
  //#region src/commands/sync.ts
6122
6676
  const { workflowHeader } = createHeader({
6123
6677
  source: "packages/astromech/src/thin-callers.ts",
6124
- tool: "holocron sync"
6678
+ tool: "holocron repo sync"
6125
6679
  });
6126
6680
  const SYNC_STEPS = [
6127
6681
  "labels",
@@ -6175,55 +6729,59 @@ async function runSync(input) {
6175
6729
  for (const stepName of SYNC_STEPS) {
6176
6730
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
6177
6731
  if (LOCAL_STEPS.has(stepName)) continue;
6178
- if (stepName === "labels") if (source.syncLabels) {
6179
- steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6180
- print(formatSyncStep(steps[steps.length - 1]));
6181
- } else {
6182
- steps.push({
6183
- capability: "source",
6184
- step: "sync labels",
6185
- status: "skip",
6186
- message: "provider does not implement syncLabels"
6187
- });
6188
- print(formatSyncStep(steps[steps.length - 1]));
6732
+ if (stepName === "labels") {
6733
+ if (source.syncLabels) {
6734
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6735
+ print(formatSyncStep(steps[steps.length - 1]));
6736
+ } else {
6737
+ steps.push({
6738
+ capability: "source",
6739
+ step: "sync labels",
6740
+ status: "skip",
6741
+ message: "provider does not implement syncLabels"
6742
+ });
6743
+ print(formatSyncStep(steps[steps.length - 1]));
6744
+ }
6189
6745
  }
6190
- if (stepName === "properties") if (source.syncProperties) {
6191
- const repo = config.repo;
6192
- const properties = {};
6193
- const effectivePreset = repo?.protection;
6194
- if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6195
- const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6196
- properties["monorepo"] = String(isMonorepo);
6197
- const manual = repo?.properties ?? {};
6198
- if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6199
- if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6200
- if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6201
- if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6202
- {
6203
- const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6204
- const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6205
- const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6206
- properties["holocron_profile"] = deriveProfile({
6207
- rootPackageJson,
6208
- repoName: basename(input.context.repoRoot),
6209
- isMonorepo,
6210
- workspacePackageJsons
6746
+ if (stepName === "properties") {
6747
+ if (source.syncProperties) {
6748
+ const repo = config.repo;
6749
+ const properties = {};
6750
+ const effectivePreset = repo?.protection;
6751
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6752
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6753
+ properties["monorepo"] = String(isMonorepo);
6754
+ const manual = repo?.properties ?? {};
6755
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6756
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6757
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6758
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6759
+ {
6760
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6761
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6762
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6763
+ properties["holocron_profile"] = deriveProfile({
6764
+ rootPackageJson,
6765
+ repoName: basename(input.context.repoRoot),
6766
+ isMonorepo,
6767
+ workspacePackageJsons
6768
+ });
6769
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6770
+ const capabilities = deriveCapabilities(config.providers);
6771
+ properties["holocron_capabilities"] = capabilities;
6772
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6773
+ }
6774
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6775
+ print(formatSyncStep(steps[steps.length - 1]));
6776
+ } else {
6777
+ steps.push({
6778
+ capability: "source",
6779
+ step: "sync properties",
6780
+ status: "skip",
6781
+ message: "provider does not implement syncProperties"
6211
6782
  });
6212
- properties["holocron_stack"] = deriveStack(rootPackageJson);
6213
- const capabilities = deriveCapabilities(config.providers);
6214
- properties["holocron_capabilities"] = capabilities;
6215
- properties["holocron_compliance"] = deriveCompliance(capabilities);
6783
+ print(formatSyncStep(steps[steps.length - 1]));
6216
6784
  }
6217
- steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6218
- print(formatSyncStep(steps[steps.length - 1]));
6219
- } else {
6220
- steps.push({
6221
- capability: "source",
6222
- step: "sync properties",
6223
- status: "skip",
6224
- message: "provider does not implement syncProperties"
6225
- });
6226
- print(formatSyncStep(steps[steps.length - 1]));
6227
6785
  }
6228
6786
  if (stepName === "teams") {
6229
6787
  const teams = config.repo?.teams ?? [];
@@ -6435,29 +6993,34 @@ async function runSync(input) {
6435
6993
  }
6436
6994
  }
6437
6995
  }
6438
- if (stepName === "scripts") if (config.syncScripts === false) {
6439
- steps.push({
6440
- capability: "local",
6441
- step: "sync scripts",
6442
- status: "skip",
6443
- message: "syncScripts: false"
6444
- });
6445
- print(formatSyncStep(steps[steps.length - 1]));
6446
- } else {
6447
- const tasksConfig = { tasks: config.tasks };
6448
- if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6449
- const desired = createAstromech({
6450
- cwd: input.context.repoRoot,
6451
- config: tasksConfig,
6452
- logger
6453
- }).packageScripts();
6454
- steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6455
- const changed = await mergePackageJsonScripts(input.context.repoRoot, desired);
6456
- if (changed === null) return "no package.json";
6457
- if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6458
- return `${changed.join(", ")} set`;
6459
- }));
6460
- print(formatSyncStep(steps[steps.length - 1]));
6996
+ if (stepName === "scripts") {
6997
+ if (config.syncScripts === false) {
6998
+ steps.push({
6999
+ capability: "local",
7000
+ step: "sync scripts",
7001
+ status: "skip",
7002
+ message: "syncScripts: false"
7003
+ });
7004
+ print(formatSyncStep(steps[steps.length - 1]));
7005
+ } else {
7006
+ const tasksConfig = { tasks: config.tasks };
7007
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
7008
+ const astromechScripts = createAstromech({
7009
+ cwd: input.context.repoRoot,
7010
+ config: tasksConfig,
7011
+ logger
7012
+ });
7013
+ const desired = astromechScripts.packageScripts();
7014
+ const turboContent = astromechScripts.turboConfig();
7015
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
7016
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
7017
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
7018
+ if (changed === null) return "no package.json";
7019
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
7020
+ return `${changed.join(", ")} set`;
7021
+ }));
7022
+ print(formatSyncStep(steps[steps.length - 1]));
7023
+ }
6461
7024
  }
6462
7025
  if (stepName === "turbo") {
6463
7026
  const astromechTurbo = createAstromech({
@@ -6479,9 +7042,10 @@ async function runSync(input) {
6479
7042
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6480
7043
  }));
6481
7044
  print(formatSyncStep(steps[steps.length - 1]));
6482
- const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
7045
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
7046
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6483
7047
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6484
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
7048
+ const rootScripts = rootPkg?.scripts ?? {};
6485
7049
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6486
7050
  if (workspaceFix.changed) {
6487
7051
  steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
@@ -6489,6 +7053,15 @@ async function runSync(input) {
6489
7053
  }));
6490
7054
  print(formatSyncStep(steps[steps.length - 1]));
6491
7055
  }
7056
+ if (rootPkgRaw) {
7057
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
7058
+ if (depFix.changed) {
7059
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
7060
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
7061
+ }));
7062
+ print(formatSyncStep(steps[steps.length - 1]));
7063
+ }
7064
+ }
6492
7065
  }
6493
7066
  }
6494
7067
  if (stepName === "wiki") {
@@ -6546,7 +7119,7 @@ async function runSyncStep(capability, step, dryRun, body) {
6546
7119
  capability,
6547
7120
  step,
6548
7121
  status: "fail",
6549
- message: err instanceof Error ? err.message : String(err)
7122
+ message: errorMessage(err)
6550
7123
  };
6551
7124
  }
6552
7125
  }
@@ -6572,8 +7145,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6572
7145
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6573
7146
  * listed keys, never touches the rest. Returns the sorted list of keys that
6574
7147
  * drifted (empty when all current), or `null` when there is no `package.json`.
7148
+ *
7149
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
7150
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
7151
+ * only, an *existing* script that isn't the generated `holocron run <task>
7152
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
7153
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
7154
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
7155
+ * real command and, once `turbo.json` exists and root is a real workspace
7156
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
7157
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
7158
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
7159
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
7160
+ * legitimate direct-command alternative and stays fully generator-owned.
6575
7161
  */
6576
- async function mergePackageJsonScripts(repoRoot, desired) {
7162
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6577
7163
  const pkgPath = join(repoRoot, "package.json");
6578
7164
  let content;
6579
7165
  try {
@@ -6584,15 +7170,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6584
7170
  const pkg = JSON.parse(content);
6585
7171
  const scripts = pkg.scripts ?? {};
6586
7172
  const changed = [];
6587
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6588
- scripts[name] = command;
6589
- changed.push(name);
7173
+ for (const [name, command] of Object.entries(desired)) {
7174
+ const existing = scripts[name];
7175
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
7176
+ if (existing !== command) {
7177
+ scripts[name] = command;
7178
+ changed.push(name);
7179
+ }
6590
7180
  }
6591
7181
  if (changed.length === 0) return [];
6592
7182
  pkg.scripts = scripts;
6593
7183
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6594
7184
  return changed.sort();
6595
7185
  }
7186
+ /**
7187
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
7188
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
7189
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
7190
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
7191
+ * rather than the whole string means an old wrapper survives a
7192
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
7193
+ * to update to the new one), not mistaken for a real command.
7194
+ */
7195
+ function isGeneratedRunWrapper(command, taskName) {
7196
+ return command.endsWith(` run ${taskName} --`);
7197
+ }
6596
7198
  const README_DESC_START = "<!-- holocron:description -->";
6597
7199
  const README_DESC_END = "<!-- /holocron:description -->";
6598
7200
  async function updateReadmeDescription(repoRoot, description) {
@@ -6809,7 +7411,7 @@ const DEFAULT_REPO = "theholocron/.github";
6809
7411
  * The file set `sync-github` pushes to `theholocron/.github` — the reusable
6810
7412
  * `workflow_call` implementations, composite actions and starter-workflow
6811
7413
  * metadata, generated by `@theholocron/astromech`. Secondary repos get nothing:
6812
- * their thin callers are managed by `holocron sync --steps workflows`, which
7414
+ * their thin callers are managed by `holocron repo sync --steps workflows`, which
6813
7415
  * executes the resolved config (regex parsing here can't resolve preset spreads
6814
7416
  * like `...tasks` from `nodeDocs()` — see issue #464).
6815
7417
  */
@@ -6835,7 +7437,7 @@ async function runSyncGithub(input) {
6835
7437
  token,
6836
7438
  fetch: input.fetch
6837
7439
  });
6838
- print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
7440
+ print(`holocron sync github${dryRun ? " (dry-run)" : ""}`);
6839
7441
  print(` repo: ${repo}`);
6840
7442
  if (branch) print(` branch: ${branch}`);
6841
7443
  print("");
@@ -6964,7 +7566,7 @@ async function runSyncGithub(input) {
6964
7566
  sha: blob.sha
6965
7567
  });
6966
7568
  } catch (err) {
6967
- const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
7569
+ const msg = `failed to create blob for ${file.path}: ${errorMessage(err)}`;
6968
7570
  print(` ✗ ${msg}`);
6969
7571
  return done({
6970
7572
  status: "fail",
@@ -6978,7 +7580,7 @@ async function runSyncGithub(input) {
6978
7580
  try {
6979
7581
  newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
6980
7582
  } catch (err) {
6981
- const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
7583
+ const msg = `failed to create tree: ${errorMessage(err)}`;
6982
7584
  print(` ✗ ${msg}`);
6983
7585
  return done({
6984
7586
  status: "fail",
@@ -6992,7 +7594,7 @@ async function runSyncGithub(input) {
6992
7594
  try {
6993
7595
  newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
6994
7596
  } catch (err) {
6995
- const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
7597
+ const msg = `failed to create commit: ${errorMessage(err)}`;
6996
7598
  print(` ✗ ${msg}`);
6997
7599
  return done({
6998
7600
  status: "fail",
@@ -7011,7 +7613,7 @@ async function runSyncGithub(input) {
7011
7613
  }
7012
7614
  else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
7013
7615
  } catch (err) {
7014
- const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
7616
+ const msg = `failed to update ref: ${errorMessage(err)}`;
7015
7617
  print(` ✗ ${msg}`);
7016
7618
  return done({
7017
7619
  status: "fail",
@@ -7027,12 +7629,12 @@ async function runSyncGithub(input) {
7027
7629
  title: message.split("\n")[0],
7028
7630
  head: branch,
7029
7631
  base: "main",
7030
- body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
7632
+ body: "Auto-generated by `holocron sync github`. Review and merge to apply template updates."
7031
7633
  })).html_url;
7032
7634
  print(` → PR opened: ${prUrl}`);
7033
7635
  } catch (err) {
7034
7636
  if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
7035
- else print(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
7637
+ else print(` ⚠ PR creation failed: ${errorMessage(err)}`);
7036
7638
  }
7037
7639
  return done({
7038
7640
  status: "ok",
@@ -7045,7 +7647,7 @@ async function runSyncGithub(input) {
7045
7647
  //#endregion
7046
7648
  //#region src/commands/upgrade-deps.ts
7047
7649
  /**
7048
- * `holocron upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7650
+ * `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7049
7651
  * its `holocron.config.ts` up to the current major.
7050
7652
  *
7051
7653
  * Two concerns:
@@ -7060,7 +7662,7 @@ async function runSyncGithub(input) {
7060
7662
  * This applies the mechanical transforms and flags anything that needs a
7061
7663
  * human eye.
7062
7664
  *
7063
- * After running, `pnpm install && holocron ci`.
7665
+ * After running, `pnpm install && holocron run ci`.
7064
7666
  */
7065
7667
  const FETCH_TIMEOUT_MS$1 = 4e3;
7066
7668
  /** Resolve a package's `latest` dist-tag from the npm registry. */
@@ -7127,7 +7729,7 @@ function isLegacyConfig(content) {
7127
7729
  }
7128
7730
  /**
7129
7731
  * Apply the mechanical 7.x → 8.x transforms to a `holocron.config.ts`. Best
7130
- * effort — the result should always be reviewed and run through `holocron ci`.
7732
+ * effort — the result should always be reviewed and run through `holocron run ci`.
7131
7733
  */
7132
7734
  function migrateConfig(content) {
7133
7735
  const transforms = [];
@@ -7253,7 +7855,7 @@ async function runUpgradeDeps(input = {}) {
7253
7855
  print("");
7254
7856
  if (bumps.length === 0 && !configMigrated) print("Nothing to do — already up to date.");
7255
7857
  else {
7256
- print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron ci\``);
7858
+ print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron run ci\``);
7257
7859
  if (configMigrated) print(" review holocron.config.ts before committing.");
7258
7860
  }
7259
7861
  const status = dryRun ? "dry-run" : "ok";
@@ -7459,6 +8061,63 @@ async function runUpgradeNode(input) {
7459
8061
  };
7460
8062
  }
7461
8063
  //#endregion
8064
+ //#region src/commit-lint/lint-message.ts
8065
+ /**
8066
+ * Lints one commit message string against an already-loaded commitlint
8067
+ * config. The genuinely environment-independent half of commit-message
8068
+ * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
8069
+ * caller (Sentinel isolates itself from any ambient config since it runs
8070
+ * standalone, away from a real repo checkout; `lintCommitMsgFile` below
8071
+ * just lets cosmiconfig discover the invoking repo's own
8072
+ * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
8073
+ * but linting one message against a loaded config is identical either way.
8074
+ *
8075
+ * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
8076
+ * itself uses internally — never a reimplementation of commitlint's rules.
8077
+ */
8078
+ /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
8079
+ function selectParserOpts(parserPreset) {
8080
+ return parserPreset?.parserOpts;
8081
+ }
8082
+ async function lintCommitMessage(message, loaded) {
8083
+ const opts = {
8084
+ /* istanbul ignore next -- see comment above */
8085
+ parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
8086
+ plugins: loaded.plugins,
8087
+ /* istanbul ignore next -- see comment above */
8088
+ ignores: loaded.ignores ?? [],
8089
+ defaultIgnores: loaded.defaultIgnores !== false
8090
+ };
8091
+ const outcome = await lint(message, loaded.rules, opts);
8092
+ if (outcome.valid) return [];
8093
+ return outcome.errors.map((error) => ({
8094
+ rule: error.name,
8095
+ message: error.message
8096
+ }));
8097
+ }
8098
+ //#endregion
8099
+ //#region src/commit-lint/lint-commit-msg-file.ts
8100
+ /**
8101
+ * `holocron run lint commit-msg <file>` — a real programmatic replacement for
8102
+ * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
8103
+ * (holocron#789). Matches that CLI's own contract exactly: `file` is a
8104
+ * path to the commit message being written (the arg git's `commit-msg`
8105
+ * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
8106
+ * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
8107
+ * discovers the invoking repo's real `commitlint.config.*` file, exactly
8108
+ * as `commitlint --edit` (no `--config` flag) already does today. Unlike
8109
+ * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
8110
+ * runs inside a real repo checkout with a real `node_modules`, not
8111
+ * standalone on a server.
8112
+ */
8113
+ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8114
+ const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
8115
+ return {
8116
+ valid: violations.length === 0,
8117
+ violations
8118
+ };
8119
+ }
8120
+ //#endregion
7462
8121
  //#region src/config/load-config.ts
7463
8122
  /**
7464
8123
  * `holocron.config.{ts,js,mjs,cjs,json}` loader.
@@ -7473,14 +8132,19 @@ async function runUpgradeNode(input) {
7473
8132
  */
7474
8133
  const execFileAsync = promisify(execFile);
7475
8134
  /**
7476
- * Read + parse + resolve `holocron.config.*` from the given directory.
8135
+ * Read + parse + resolve `holocron.config.*` starting from the given
8136
+ * directory, walking up through ancestors to the nearest `.git` boundary
8137
+ * when `cwd` itself has no match — every command is usable from any
8138
+ * subdirectory of a repo, not only its exact root (see
8139
+ * `@theholocron/datapad`'s own doc comment for the stop conditions).
7477
8140
  * Throws {@link ConfigFileError} when nothing is found or a file cannot
7478
- * be loaded, or `ConfigError` when the config is invalid.
8141
+ * be loaded, or `ConfigError` when the config fails schema checks.
7479
8142
  */
7480
8143
  async function loadConfig(cwd) {
7481
8144
  const found = await loadConfigFile({
7482
8145
  cwd,
7483
- name: "holocron"
8146
+ name: "holocron",
8147
+ walkUp: true
7484
8148
  });
7485
8149
  if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
7486
8150
  return {
@@ -7553,6 +8217,15 @@ function parseGitRemoteUrl(url) {
7553
8217
  * {@link promptForPositionals} for just that one command — no spawn needed,
7554
8218
  * the handler already has everything else it needs.
7555
8219
  *
8220
+ * For a command whose handler honours `--dry-run` ({@link
8221
+ * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8222
+ * dry run doesn't mutate anything, so there's nothing to ask permission
8223
+ * for — then, once that preview comes back clean and found something to
8224
+ * do, asks the one real decision: "proceed with the real run?". A preview
8225
+ * that finds nothing to do skips that question too. `--skip-dry-run` opts
8226
+ * a top-level invocation out of the preview entirely, straight to the
8227
+ * real run, for when you already know what you want.
8228
+ *
7556
8229
  * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
7557
8230
  * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
7558
8231
  * hanging on `@inquirer/prompts` (which otherwise throws its own
@@ -7566,40 +8239,153 @@ var NonInteractiveError = class extends Error {
7566
8239
  };
7567
8240
  const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
7568
8241
  /**
8242
+ * This repo's own resolved task manifest, filtered to what's actually
8243
+ * runnable locally — `Astromech.runnableTasks()`, reused rather than
8244
+ * reimplemented (deliberately *not* `packageScripts()`, whose
8245
+ * `syncScripts: false` short-circuit returns `{}` for repos — this one
8246
+ * included — that opt out of generated `package.json` scripts but still
8247
+ * have real runnable tasks). Empty (never throws) when there's no config to
8248
+ * resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
8249
+ * as "run ›" simply having no children.
8250
+ */
8251
+ async function listRunnableTasks(argv) {
8252
+ const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
8253
+ const config = await loadTasksConfig(cwd).catch(() => void 0);
8254
+ return createAstromech({
8255
+ cwd,
8256
+ config
8257
+ }).runnableTasks();
8258
+ }
8259
+ /**
8260
+ * Namespace display order for the `run` menu — deliberately its own thing,
8261
+ * not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
8262
+ * signal first" in an automated gate, a different goal from "how a person
8263
+ * would naturally work through a repo" (build it, clean up its style, check
8264
+ * it's secure, test it, validate it, document it). A namespace absent here
8265
+ * (a repo-specific one this list doesn't know about) sorts after every
8266
+ * named one, in whatever order it was first seen.
8267
+ */
8268
+ const RUN_MENU_NAMESPACE_ORDER = [
8269
+ "delivery",
8270
+ "sourceQuality",
8271
+ "security",
8272
+ "verification",
8273
+ "platform",
8274
+ "knowledge"
8275
+ ];
8276
+ /**
8277
+ * Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
8278
+ * groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
8279
+ * within each group is `taskNames`' own order (already declaration order,
8280
+ * from {@link listRunnableTasks}). A name with no `.` at all (defensive;
8281
+ * every real registry task has a namespace) is its own bucket, keyed by the
8282
+ * full name, sorted after every namespaced group.
8283
+ */
8284
+ function groupByNamespace(taskNames) {
8285
+ const groups = /* @__PURE__ */ new Map();
8286
+ for (const name of taskNames) {
8287
+ const dot = name.indexOf(".");
8288
+ const key = dot === -1 ? name : name.slice(0, dot);
8289
+ const list = groups.get(key);
8290
+ if (list) list.push(name);
8291
+ else groups.set(key, [name]);
8292
+ }
8293
+ const ordered = /* @__PURE__ */ new Map();
8294
+ const byMenuOrder = (a, b) => {
8295
+ const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
8296
+ const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
8297
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
8298
+ return 0;
8299
+ };
8300
+ for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8301
+ return ordered;
8302
+ }
8303
+ /**
8304
+ * Dynamically-built `run` sub-entries for every locally-runnable task
8305
+ * ({@link listRunnableTasks}), grouped by namespace and merged into
8306
+ * `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
8307
+ * itself, since the task set is per-repo, not a static surface this module
8308
+ * can hand-enumerate.
8309
+ *
8310
+ * Each namespace becomes a group with a leading "<namespace> (run it)" item
8311
+ * (`holocron run <namespace>` — every task in it, via
8312
+ * `Astromech.runGroup()`) alongside each of its individual tasks, the exact
8313
+ * same "a group that's also runnable" shape `menuItems` already renders for
8314
+ * `repo sync` / `deploy` — reached here for free by using a space, not the
8315
+ * task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
8316
+ * the real dotted invocation `buildChildArgv` actually spawns).
8317
+ */
8318
+ function buildRunMenuEntries(taskNames) {
8319
+ const entries = [];
8320
+ for (const [namespace, tasks] of groupByNamespace(taskNames)) {
8321
+ const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
8322
+ if (isRealNamespace) entries.push({
8323
+ name: `run ${namespace}`,
8324
+ description: `Run every ${namespace}.* task in sequence`,
8325
+ positionals: [],
8326
+ supportsDryRun: true,
8327
+ spawnArgs: ["run", namespace]
8328
+ });
8329
+ for (const task of tasks) {
8330
+ const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
8331
+ entries.push({
8332
+ name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
8333
+ description: `Run ${task}`,
8334
+ positionals: [],
8335
+ supportsDryRun: true,
8336
+ spawnArgs: ["run", task]
8337
+ });
8338
+ }
8339
+ }
8340
+ return entries;
8341
+ }
8342
+ /**
7569
8343
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
7570
8344
  * yargs registrations (this module does not introspect yargs at runtime).
7571
- * `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
7572
- * interactive prompt in front of it would work against that.
8345
+ * `run <task>` has no entry here at all — `cli.ts`'s `$0` handler merges in
8346
+ * {@link buildRunMenuEntries}'s dynamically-built ones instead, since the
8347
+ * task namespace is this repo's own resolved manifest, not a static set
8348
+ * this module can hand-enumerate the way every other entry's positionals
8349
+ * are. `job` and `passthrough` stay un-prompted either way; picking a task
8350
+ * and running it plain covers the common case, and typing
8351
+ * `holocron run <task> <job> -- <args>` directly is one step away for
8352
+ * everything else. `run lint commit-msg <file>` stays excluded entirely:
8353
+ * it's invoked exclusively from `.husky/commit-msg "$1"`, never typed by a
8354
+ * human.
7573
8355
  */
7574
8356
  const COMMAND_REGISTRY = [
7575
- {
7576
- name: "version",
7577
- description: "Print the CLI version",
7578
- positionals: []
7579
- },
7580
8357
  {
7581
8358
  name: "clone",
7582
- description: "Clone all repos in a GitHub org as siblings under a single directory",
7583
- positionals: []
8359
+ description: "Clone a single repo (org/repo) or every repo in an org (org)",
8360
+ positionals: [{
8361
+ key: "target",
8362
+ message: "org/repo to clone (or a bare org to clone every repo in it):",
8363
+ type: "input",
8364
+ validate: (v) => v.trim().length > 0 || "org/repo or org is required"
8365
+ }],
8366
+ supportsDryRun: true
7584
8367
  },
7585
8368
  {
7586
8369
  name: "doctor",
7587
8370
  description: "Load the config and run a smoke check against every provider",
7588
- positionals: []
8371
+ positionals: [],
8372
+ supportsDryRun: true
7589
8373
  },
7590
8374
  {
7591
- name: "setup",
8375
+ name: "repo setup",
7592
8376
  description: "Apply infra setup actions across every configured capability",
7593
- positionals: []
8377
+ positionals: [],
8378
+ supportsDryRun: true
7594
8379
  },
7595
8380
  {
7596
- name: "secret set",
8381
+ name: "secrets set",
7597
8382
  description: "Set a single secret via the configured `secrets` capability",
7598
8383
  positionals: [{
7599
8384
  key: "name",
7600
8385
  message: "Secret name:",
7601
8386
  type: "input"
7602
- }]
8387
+ }],
8388
+ supportsDryRun: true
7603
8389
  },
7604
8390
  {
7605
8391
  name: "secrets sync",
@@ -7608,7 +8394,8 @@ const COMMAND_REGISTRY = [
7608
8394
  key: "environmentId",
7609
8395
  message: "Vault environment id:",
7610
8396
  type: "input"
7611
- }]
8397
+ }],
8398
+ supportsDryRun: true
7612
8399
  },
7613
8400
  {
7614
8401
  name: "deploy",
@@ -7617,52 +8404,66 @@ const COMMAND_REGISTRY = [
7617
8404
  key: "branch",
7618
8405
  message: "Branch to deploy:",
7619
8406
  type: "input"
7620
- }]
8407
+ }],
8408
+ supportsDryRun: true
7621
8409
  },
7622
8410
  {
7623
- name: "cleanup-preview",
8411
+ name: "deploy cleanup-preview",
7624
8412
  description: "List and delete Cloudflare Pages preview deployments for a GitHub PR",
7625
8413
  positionals: [{
7626
8414
  key: "pr",
7627
8415
  message: "PR number:",
7628
8416
  type: "input",
7629
8417
  validate: numeric
7630
- }]
8418
+ }],
8419
+ supportsDryRun: true
7631
8420
  },
7632
8421
  {
7633
- name: "bump-versions",
8422
+ name: "deploy on-release",
8423
+ description: "Deploy every workspace package whose delivery.deploy task is `on: release`",
8424
+ positionals: [],
8425
+ supportsDryRun: true
8426
+ },
8427
+ {
8428
+ name: "package bump-versions",
7634
8429
  description: "Bump all non-private package versions in lockstep (semantic-release prepareCmd)",
7635
8430
  positionals: [{
7636
8431
  key: "newVersion",
7637
8432
  cliArg: "new-version",
7638
8433
  message: "New version:",
7639
8434
  type: "input"
7640
- }]
8435
+ }],
8436
+ supportsDryRun: true
7641
8437
  },
7642
8438
  {
7643
- name: "publish",
8439
+ name: "package publish",
7644
8440
  description: "Publish @theholocron/* packages to npm",
7645
- positionals: []
8441
+ positionals: [],
8442
+ supportsDryRun: true
7646
8443
  },
7647
8444
  {
7648
- name: "sync",
8445
+ name: "repo sync",
7649
8446
  description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
7650
- positionals: []
8447
+ positionals: [],
8448
+ supportsDryRun: true
7651
8449
  },
7652
8450
  {
7653
- name: "ci",
8451
+ name: "run ci",
7654
8452
  description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
7655
- positionals: []
8453
+ positionals: [],
8454
+ supportsDryRun: true
7656
8455
  },
7657
8456
  {
7658
- name: "sync-github",
8457
+ name: "sync github",
7659
8458
  description: "Sync workflow templates and composite actions to theholocron/.github",
7660
- positionals: []
8459
+ positionals: [],
8460
+ supportsDryRun: true
7661
8461
  },
7662
8462
  {
7663
- name: "sync-readme",
8463
+ name: "repo sync readme",
7664
8464
  description: "Sync the Installation + Usage block in README.md from package.json",
7665
- positionals: []
8465
+ positionals: [],
8466
+ supportsDryRun: true
7666
8467
  },
7667
8468
  {
7668
8469
  name: "config show",
@@ -7670,12 +8471,13 @@ const COMMAND_REGISTRY = [
7670
8471
  positionals: []
7671
8472
  },
7672
8473
  {
7673
- name: "new",
8474
+ name: "new repo",
7674
8475
  description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
7675
- positionals: []
8476
+ positionals: [],
8477
+ supportsDryRun: true
7676
8478
  },
7677
8479
  {
7678
- name: "plugin create",
8480
+ name: "new plugin",
7679
8481
  description: "Scaffold a new @theholocron/holocron-plugin-<slug> package",
7680
8482
  positionals: [{
7681
8483
  key: "slug",
@@ -7685,28 +8487,29 @@ const COMMAND_REGISTRY = [
7685
8487
  key: "vendor",
7686
8488
  message: "Vendor display name (PascalCase):",
7687
8489
  type: "input"
7688
- }]
8490
+ }],
8491
+ supportsDryRun: true
7689
8492
  },
7690
8493
  {
7691
8494
  name: "skills install",
7692
8495
  description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
7693
8496
  positionals: [],
7694
- group: "skills"
8497
+ supportsDryRun: true
7695
8498
  },
7696
8499
  {
7697
8500
  name: "skills remove",
7698
8501
  description: "Remove installed skills via npx skills remove",
7699
8502
  positionals: [],
7700
- group: "skills"
8503
+ supportsDryRun: true
7701
8504
  },
7702
8505
  {
7703
8506
  name: "skills update",
7704
8507
  description: "Update installed skills to their latest upstream versions via npx skills update",
7705
8508
  positionals: [],
7706
- group: "skills"
8509
+ supportsDryRun: true
7707
8510
  },
7708
8511
  {
7709
- name: "upgrade node",
8512
+ name: "repo upgrade node",
7710
8513
  description: "Scan the repo and update every Node.js version pin to a new major",
7711
8514
  positionals: [{
7712
8515
  key: "to",
@@ -7714,13 +8517,13 @@ const COMMAND_REGISTRY = [
7714
8517
  type: "input",
7715
8518
  validate: numeric
7716
8519
  }],
7717
- group: "upgrade"
8520
+ supportsDryRun: true
7718
8521
  },
7719
8522
  {
7720
- name: "upgrade deps",
8523
+ name: "repo upgrade deps",
7721
8524
  description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
7722
8525
  positionals: [],
7723
- group: "upgrade"
8526
+ supportsDryRun: true
7724
8527
  },
7725
8528
  {
7726
8529
  name: "auth set",
@@ -7729,8 +8532,7 @@ const COMMAND_REGISTRY = [
7729
8532
  key: "provider",
7730
8533
  message: "Provider name:",
7731
8534
  type: "input"
7732
- }],
7733
- group: "auth"
8535
+ }]
7734
8536
  },
7735
8537
  {
7736
8538
  name: "auth unset",
@@ -7740,8 +8542,7 @@ const COMMAND_REGISTRY = [
7740
8542
  message: "Provider:",
7741
8543
  type: "select",
7742
8544
  choices: listStoredProviders
7743
- }],
7744
- group: "auth"
8545
+ }]
7745
8546
  },
7746
8547
  {
7747
8548
  name: "auth check",
@@ -7751,14 +8552,12 @@ const COMMAND_REGISTRY = [
7751
8552
  message: "Provider:",
7752
8553
  type: "select",
7753
8554
  choices: listStoredProviders
7754
- }],
7755
- group: "auth"
8555
+ }]
7756
8556
  },
7757
8557
  {
7758
8558
  name: "auth list",
7759
8559
  description: "List every provider with a stored bootstrap token",
7760
- positionals: [],
7761
- group: "auth"
8560
+ positionals: []
7762
8561
  }
7763
8562
  ];
7764
8563
  /** Look up a {@link CommandEntry} by its full name — for Layer 3 call sites in `cli.ts`. */
@@ -7767,36 +8566,89 @@ function getEntry(name) {
7767
8566
  if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
7768
8567
  return entry;
7769
8568
  }
8569
+ const GROUP = "group:";
8570
+ /** The "← back" item's value; only offered below the first level. */
8571
+ const BACK = "back";
8572
+ /**
8573
+ * One level of the menu, derived from the registered command names: the next word
8574
+ * of every entry under `path` (`[]` is the top). A word is
8575
+ *
8576
+ * - a **command** when nothing is nested under it (`doctor`);
8577
+ * - a **group** when commands are nested under it (`repo` → `repo setup`, `repo sync`, …),
8578
+ * which can nest as deep as the names go (`repo upgrade` → `repo upgrade node`);
8579
+ *
8580
+ * Every row is a single word, never a "<command> <subcommand>" path: `config show` is
8581
+ * `config ›` then `show`, even when a group holds just one command.
8582
+ *
8583
+ * A path that is itself runnable (`repo sync`, `deploy`) gets a leading "<word> (run it)" item.
8584
+ * Registration order is kept.
8585
+ */
8586
+ function menuItems(entries, path = []) {
8587
+ const prefix = path.join(" ");
8588
+ const own = prefix ? entries.find((e) => e.name === prefix) : void 0;
8589
+ const under = prefix ? entries.filter((e) => e.name.startsWith(`${prefix} `)) : entries;
8590
+ const haystackOf = (list) => list.map((e) => `${e.name} ${e.description}`.toLowerCase()).join(" ");
8591
+ const items = [];
8592
+ if (own) items.push({
8593
+ name: `${path[path.length - 1]} (run it)`,
8594
+ value: own.name,
8595
+ description: own.description,
8596
+ haystack: haystackOf([own])
8597
+ });
8598
+ const words = [...new Set(under.map((e) => e.name.split(" ")[path.length]))];
8599
+ for (const word of words) {
8600
+ const full = [...path, word].join(" ");
8601
+ const family = under.filter((e) => e.name === full || e.name.startsWith(`${full} `));
8602
+ const self = family.find((e) => e.name === full);
8603
+ const nested = family.filter((e) => e.name !== full);
8604
+ if (nested.length === 0 && self) items.push({
8605
+ name: word,
8606
+ value: full,
8607
+ description: self.description,
8608
+ haystack: haystackOf([self])
8609
+ });
8610
+ else {
8611
+ const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
8612
+ items.push({
8613
+ name: `${word} ›`,
8614
+ value: `${GROUP}${full}`,
8615
+ description: children.join(", "),
8616
+ haystack: haystackOf(family)
8617
+ });
8618
+ }
8619
+ }
8620
+ return items;
8621
+ }
7770
8622
  /**
7771
- * Searchable autocomplete over `entries` — the Layer 1 top-level picker, and
7772
- * (passed a `group`-filtered subset) the Layer 2 parent-command picker.
7773
- * `select()` would work too at these list sizes, but `search()` degrades
7774
- * gracefully as the surface grows and costs nothing when it doesn't.
8623
+ * Searchable autocomplete over one menu level's `items`. `select()` would work too
8624
+ * at these list sizes, but `search()` degrades gracefully as the surface grows and
8625
+ * costs nothing when it doesn't. Resolves to the picked item's `value`.
7775
8626
  */
7776
- async function pickCommand(entries, message = "What would you like to do?") {
7777
- const byName = new Map(entries.map((e) => [e.name, e]));
7778
- const picked = await search({
8627
+ async function pickMenuItem(items, message = "What would you like to do?") {
8628
+ return search({
7779
8629
  message,
7780
- source: (term) => searchChoices(entries, term)
8630
+ source: (term) => searchChoices(items, term)
7781
8631
  });
7782
- return byName.get(picked);
7783
8632
  }
7784
8633
  /**
7785
8634
  * `@inquirer/search`'s `source` callback, factored out as a plain function —
7786
8635
  * unit-testable directly instead of only through a mocked `search()` call.
7787
- * Empty/undefined `term` (nothing typed yet) returns every entry.
8636
+ * Empty/undefined `term` (nothing typed yet) returns every item.
7788
8637
  */
7789
- function searchChoices(entries, term) {
7790
- return (!term ? entries : entries.filter((e) => matches(e, term))).map((e) => ({
7791
- name: e.name,
7792
- value: e.name,
7793
- description: e.description
8638
+ function searchChoices(items, term) {
8639
+ const needle = term?.toLowerCase();
8640
+ const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
8641
+ const rank = (i) => {
8642
+ if (!needle) return 0;
8643
+ const name = i.name.toLowerCase();
8644
+ return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
8645
+ };
8646
+ return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
8647
+ name: i.name,
8648
+ value: i.value,
8649
+ description: i.description
7794
8650
  }));
7795
8651
  }
7796
- function matches(entry, term) {
7797
- const needle = term.toLowerCase();
7798
- return entry.name.toLowerCase().includes(needle) || entry.description.toLowerCase().includes(needle);
7799
- }
7800
8652
  /**
7801
8653
  * For each of `entry.positionals` not already present in `argv`, prompts for
7802
8654
  * it (`input()` or `select()`, per the positional's `type`) and returns the
@@ -7835,74 +8687,422 @@ async function promptForPositionals(entry, argv) {
7835
8687
  * menu-launched child should inherit from the parent invocation. Everything
7836
8688
  * else (command-specific options) was never captured by the top-level `$0`
7837
8689
  * handler in the first place, so there's nothing else to forward.
8690
+ *
8691
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8692
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
8693
+ * force it back off for the real-run spawn that follows, regardless of
8694
+ * what was on the original invocation.
7838
8695
  */
7839
- function forwardedFlags(argv) {
8696
+ function forwardedFlags(argv, dryRunOverride) {
7840
8697
  const out = [];
7841
8698
  const tokens = argv.token;
7842
8699
  for (const t of tokens ?? []) out.push("--token", t);
7843
8700
  if (typeof argv.org === "string") out.push("--org", argv.org);
7844
8701
  if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
7845
- if (argv.dryRun === true) out.push("--dry-run");
8702
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
7846
8703
  return out;
7847
8704
  }
7848
- /** `entry.name` split into tokens, resolved positionals appended, then forwarded flags. */
7849
- function buildChildArgv(entry, positionals, parentArgv) {
8705
+ /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8706
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
7850
8707
  return [
7851
- ...entry.name.split(" "),
8708
+ ...entry.spawnArgs ?? entry.name.split(" "),
7852
8709
  ...positionals,
7853
- ...forwardedFlags(parentArgv)
8710
+ ...forwardedFlags(parentArgv, dryRunOverride)
7854
8711
  ];
7855
8712
  }
7856
- /** Spawn `holocron <...args>` inheriting stdio; resolve with its exit code. */
7857
- function spawnChild(args) {
8713
+ /**
8714
+ * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8715
+ * its handler knows this specific `--dry-run` came from `launchMenu`'s own
8716
+ * preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
8717
+ * A handler that supports it uses this — never bare `argv.dryRun` — to
8718
+ * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
8719
+ * only has special meaning to `launchMenu` itself, so a direct/scripted
8720
+ * `--dry-run` call must never produce it — doing so unconditionally would
8721
+ * silently change the public exit-code contract for a successful dry run.
8722
+ */
8723
+ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8724
+ /**
8725
+ * Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
8726
+ *
8727
+ * `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
8728
+ * update-notifier tail still runs after this resolves (see `launchMenu`'s
8729
+ * docstring), so without this the freshly-spawned child independently runs
8730
+ * its own `checkForUpdates()` too and the notice prints twice: once from
8731
+ * the child right after its command output, once more from the parent
8732
+ * once the child exits. `extraEnv` layers on top — used only for {@link
8733
+ * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8734
+ */
8735
+ function spawnChild(args, extraEnv) {
7858
8736
  return new Promise((resolve) => {
7859
- const child = spawn(process.execPath, [process.argv[1], ...args], { stdio: "inherit" });
8737
+ const child = spawn(process.execPath, [process.argv[1], ...args], {
8738
+ stdio: "inherit",
8739
+ env: {
8740
+ ...process.env,
8741
+ NO_UPDATE_NOTIFIER: "1",
8742
+ ...extraEnv
8743
+ }
8744
+ });
7860
8745
  child.on("exit", (code) => resolve(code ?? 0));
7861
8746
  child.on("error", () => resolve(1));
7862
8747
  });
7863
8748
  }
7864
8749
  /**
7865
- * Layers 1 and 2: pick a command from `entries`, prompt for its required
7866
- * positionals, spawn it as a fresh `holocron` invocation, and set
7867
- * `process.exitCode` from the child — the caller (a `$0` handler in
7868
- * `cli.ts`) returns normally afterward so the parent's own telemetry
7869
- * flush / update-notifier tail still runs. The parent's own
7870
- * `command_completed` event fires with command name "unknown" (the
7871
- * middleware ran before any command was picked) — left as-is rather than
7872
- * suppressed; it's a real, useful signal ("the menu got used").
8750
+ * Whether `launchMenu` should auto-preview a picked command before running
8751
+ * it for real — only when the command's handler actually honours
8752
+ * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8753
+ * invocation didn't pass `--skip-dry-run` to opt out of the preview
8754
+ * entirely (an escape hatch for "I already know what I want, skip
8755
+ * straight to the real run").
8756
+ */
8757
+ function shouldPreviewDryRun(entry, parentArgv) {
8758
+ return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8759
+ }
8760
+ /**
8761
+ * Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
8762
+ * `[]` for the top), prompt for the picked command's required positionals, spawn
8763
+ * it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
8764
+ * the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
8765
+ * parent's own telemetry flush / update-notifier tail still runs. The parent's
8766
+ * own `command_completed` event fires with command name "unknown" (the middleware
8767
+ * ran before any command was picked) — left as-is rather than suppressed; it's a
8768
+ * real, useful signal ("the menu got used").
8769
+ *
8770
+ * Picking a group opens its submenu, which has a "← back" item; a command ends
8771
+ * the walk. See {@link menuItems} for how a level is built.
8772
+ *
8773
+ * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8774
+ * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8775
+ * this always previews first — a dry run doesn't mutate anything, so
8776
+ * there's nothing to ask permission for. A preview that exits non-zero
8777
+ * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8778
+ * would change) stops here too, skipping the follow-up question since the
8779
+ * real run would have nothing to do either. Otherwise it asks "proceed
8780
+ * with the real run?" before the real spawn — the one actual decision
8781
+ * left to make, since this one does mutate something.
7873
8782
  */
7874
- async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
8783
+ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
7875
8784
  if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
7876
- const picked = await pickCommand(entries, pickMessage);
7877
- const childArgv = buildChildArgv(picked, await promptForPositionals(picked, {}), parentArgv);
7878
- process.exitCode = await spawnChild(childArgv);
8785
+ const stack = [startPath];
8786
+ let picked;
8787
+ while (!picked) {
8788
+ const path = stack[stack.length - 1];
8789
+ const items = menuItems(entries, path);
8790
+ if (stack.length > 1) items.push({
8791
+ name: "← back",
8792
+ value: BACK,
8793
+ description: "Return to the previous menu",
8794
+ haystack: "back"
8795
+ });
8796
+ const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
8797
+ if (value === "back") stack.pop();
8798
+ else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
8799
+ else picked = entries.find((e) => e.name === value);
8800
+ }
8801
+ const positionals = await promptForPositionals(picked, {});
8802
+ if (shouldPreviewDryRun(picked, parentArgv)) {
8803
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8804
+ if (dryExit === 2) {
8805
+ process.exitCode = 0;
8806
+ return;
8807
+ }
8808
+ if (dryExit !== 0) {
8809
+ process.exitCode = dryExit;
8810
+ return;
8811
+ }
8812
+ if (!await confirm({
8813
+ message: "Dry run complete — proceed with the real run?",
8814
+ default: false
8815
+ })) {
8816
+ process.exitCode = 0;
8817
+ return;
8818
+ }
8819
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8820
+ return;
8821
+ }
8822
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
7879
8823
  }
7880
8824
  //#endregion
7881
- //#region src/update-notifier.ts
7882
- const PACKAGE_NAME = "@theholocron/cli";
7883
- const CACHE_TTL_MS = 1440 * 60 * 1e3;
7884
- const FETCH_TIMEOUT_MS = 3e3;
7885
- function getCacheDir() {
7886
- return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
8825
+ //#region src/ui/json-view.ts
8826
+ function isContainer(value) {
8827
+ return typeof value === "object" && value !== null;
7887
8828
  }
7888
- function getCachePath() {
7889
- return join(getCacheDir(), "update-check.json");
8829
+ function childrenOf(value) {
8830
+ return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
7890
8831
  }
7891
- function readCache() {
7892
- try {
7893
- return JSON.parse(readFileSync(getCachePath(), "utf8"));
7894
- } catch {
7895
- return null;
7896
- }
8832
+ function scalar(value) {
8833
+ if (typeof value === "string") return chalk.green(JSON.stringify(value));
8834
+ if (typeof value === "number") return chalk.yellow(String(value));
8835
+ if (typeof value === "boolean") return chalk.magenta(String(value));
8836
+ return chalk.dim(String(value === void 0 ? null : value));
7897
8837
  }
7898
- function writeCache(entry) {
7899
- try {
7900
- mkdirSync(getCacheDir(), { recursive: true });
7901
- writeFileSync(getCachePath(), JSON.stringify(entry));
7902
- } catch {}
8838
+ function summary(value) {
8839
+ const n = childrenOf(value).length;
8840
+ const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
8841
+ return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
7903
8842
  }
7904
- async function fetchLatestVersion(channel) {
7905
- try {
8843
+ /**
8844
+ * Paths of every container deeper than `depth` levels are left out, so those
8845
+ * render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
8846
+ */
8847
+ function expandToDepth(value, depth) {
8848
+ const out = /* @__PURE__ */ new Set();
8849
+ const walk = (v, path, level) => {
8850
+ if (!isContainer(v) || level >= depth) return;
8851
+ out.add(path);
8852
+ for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
8853
+ };
8854
+ walk(value, "", 0);
8855
+ return out;
8856
+ }
8857
+ /** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
8858
+ function renderTree(value, { expanded }) {
8859
+ const lines = [];
8860
+ const walk = (v, path, level, label, trailing) => {
8861
+ const indent = " ".repeat(level);
8862
+ const prefix = `${indent}${label}`;
8863
+ if (!isContainer(v)) {
8864
+ lines.push({
8865
+ path,
8866
+ text: `${prefix}${scalar(v)}${trailing}`,
8867
+ foldable: false,
8868
+ expanded: false,
8869
+ level
8870
+ });
8871
+ return;
8872
+ }
8873
+ const kids = childrenOf(v);
8874
+ const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
8875
+ if (kids.length === 0) {
8876
+ lines.push({
8877
+ path,
8878
+ text: `${prefix}${open}${close}${trailing}`,
8879
+ foldable: false,
8880
+ expanded: false,
8881
+ level
8882
+ });
8883
+ return;
8884
+ }
8885
+ if (!expanded.has(path)) {
8886
+ lines.push({
8887
+ path,
8888
+ text: `${prefix}${summary(v)}${trailing}`,
8889
+ foldable: true,
8890
+ expanded: false,
8891
+ level
8892
+ });
8893
+ return;
8894
+ }
8895
+ lines.push({
8896
+ path,
8897
+ text: `${prefix}${open}`,
8898
+ foldable: true,
8899
+ expanded: true,
8900
+ level
8901
+ });
8902
+ kids.forEach(([k, child], i) => {
8903
+ const comma = i < kids.length - 1 ? "," : "";
8904
+ const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
8905
+ walk(child, `${path}/${k}`, level + 1, childLabel, comma);
8906
+ });
8907
+ lines.push({
8908
+ path: `${path}#close`,
8909
+ text: `${indent}${close}${trailing}`,
8910
+ foldable: false,
8911
+ expanded: false,
8912
+ level
8913
+ });
8914
+ };
8915
+ walk(value, "", 0, "", "");
8916
+ return lines;
8917
+ }
8918
+ /** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
8919
+ function reduceKey(state, key, value, rows) {
8920
+ const lines = renderTree(value, { expanded: state.expanded });
8921
+ const cur = lines[state.cursor];
8922
+ let { cursor } = state;
8923
+ const expanded = new Set(state.expanded);
8924
+ switch (key) {
8925
+ case "up":
8926
+ cursor = Math.max(0, cursor - 1);
8927
+ break;
8928
+ case "down":
8929
+ cursor = Math.min(lines.length - 1, cursor + 1);
8930
+ break;
8931
+ case "right":
8932
+ if (cur?.foldable) expanded.add(cur.path);
8933
+ break;
8934
+ case "left":
8935
+ if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
8936
+ else if (cur) {
8937
+ const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
8938
+ if (parent >= 0) cursor = parent;
8939
+ }
8940
+ break;
8941
+ case "expandAll": return {
8942
+ cursor,
8943
+ top: state.top,
8944
+ expanded: expandToDepth(value, Infinity)
8945
+ };
8946
+ case "collapseAll": return {
8947
+ cursor: 0,
8948
+ top: 0,
8949
+ expanded: /* @__PURE__ */ new Set()
8950
+ };
8951
+ }
8952
+ const count = renderTree(value, { expanded }).length;
8953
+ cursor = Math.min(cursor, count - 1);
8954
+ let top = state.top;
8955
+ if (cursor < top) top = cursor;
8956
+ if (cursor >= top + rows) top = cursor - rows + 1;
8957
+ return {
8958
+ cursor,
8959
+ top: Math.max(0, top),
8960
+ expanded
8961
+ };
8962
+ }
8963
+ const KEYMAP = {
8964
+ "\x1B[A": "up",
8965
+ k: "up",
8966
+ "\x1B[B": "down",
8967
+ j: "down",
8968
+ "\x1B[C": "right",
8969
+ l: "right",
8970
+ "\r": "right",
8971
+ " ": "right",
8972
+ "\x1B[D": "left",
8973
+ h: "left",
8974
+ E: "expandAll",
8975
+ C: "collapseAll",
8976
+ q: "quit",
8977
+ "\x1B": "quit",
8978
+ "": "quit"
8979
+ };
8980
+ function draw(out, value, state, rows) {
8981
+ const lines = renderTree(value, { expanded: state.expanded });
8982
+ const view = lines.slice(state.top, state.top + rows).map((l, i) => {
8983
+ const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
8984
+ return state.top + i === state.cursor ? chalk.inverse(row) : row;
8985
+ });
8986
+ const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
8987
+ out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
8988
+ }
8989
+ async function runInteractive(value, depth) {
8990
+ const { stdin, stdout } = process;
8991
+ if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
8992
+ const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
8993
+ let state = {
8994
+ cursor: 0,
8995
+ top: 0,
8996
+ expanded: expandToDepth(value, depth)
8997
+ };
8998
+ const wasRaw = stdin.isRaw;
8999
+ stdin.setRawMode(true);
9000
+ stdin.resume();
9001
+ stdout.write("\x1B[?1049h\x1B[?25l");
9002
+ const redraw = () => draw(stdout, value, state, rows());
9003
+ stdout.on("resize", redraw);
9004
+ try {
9005
+ redraw();
9006
+ await new Promise((resolve) => {
9007
+ const onData = (buf) => {
9008
+ const key = KEYMAP[buf.toString()];
9009
+ if (key === "quit") {
9010
+ stdin.off("data", onData);
9011
+ resolve();
9012
+ return;
9013
+ }
9014
+ if (key) {
9015
+ state = reduceKey(state, key, value, rows());
9016
+ redraw();
9017
+ }
9018
+ };
9019
+ stdin.on("data", onData);
9020
+ });
9021
+ } finally {
9022
+ stdout.off("resize", redraw);
9023
+ stdout.write("\x1B[?25h\x1B[?1049l");
9024
+ stdin.setRawMode(wasRaw);
9025
+ stdin.pause();
9026
+ }
9027
+ }
9028
+ /**
9029
+ * Print `value` as JSON. Non-TTY stdout always gets plain, valid
9030
+ * `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
9031
+ * coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
9032
+ */
9033
+ async function viewJson(value, options = {}) {
9034
+ const depth = options.depth ?? Infinity;
9035
+ if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
9036
+ if (!process.stdout.isTTY) {
9037
+ console.log(JSON.stringify(value, null, 2));
9038
+ return;
9039
+ }
9040
+ const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
9041
+ console.log(lines.map((l) => l.text).join("\n"));
9042
+ }
9043
+ //#endregion
9044
+ //#region src/ui/open-path.ts
9045
+ /** Platform-appropriate command to open a path in the OS's file browser. */
9046
+ function openCommand(path) {
9047
+ if (process.platform === "darwin") return {
9048
+ cmd: "open",
9049
+ args: [path]
9050
+ };
9051
+ if (process.platform === "win32") return {
9052
+ cmd: "cmd",
9053
+ args: [
9054
+ "/c",
9055
+ "start",
9056
+ "",
9057
+ path
9058
+ ]
9059
+ };
9060
+ return {
9061
+ cmd: "xdg-open",
9062
+ args: [path]
9063
+ };
9064
+ }
9065
+ /**
9066
+ * Open `path` in the OS's default file browser (Finder / Explorer /
9067
+ * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
9068
+ * it outlives this process, and never throws when the opener binary isn't
9069
+ * on PATH (e.g. no `xdg-open` in a minimal container).
9070
+ */
9071
+ function openPath(path) {
9072
+ const { cmd, args } = openCommand(path);
9073
+ const child = spawn(cmd, args, {
9074
+ stdio: "ignore",
9075
+ detached: true
9076
+ });
9077
+ child.on("error", () => {});
9078
+ child.unref();
9079
+ }
9080
+ //#endregion
9081
+ //#region src/update-notifier.ts
9082
+ const PACKAGE_NAME = "@theholocron/cli";
9083
+ const CACHE_TTL_MS = 864e5;
9084
+ const FETCH_TIMEOUT_MS = 3e3;
9085
+ function getCacheDir() {
9086
+ return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
9087
+ }
9088
+ function getCachePath() {
9089
+ return join(getCacheDir(), "update-check.json");
9090
+ }
9091
+ function readCache() {
9092
+ try {
9093
+ return JSON.parse(readFileSync(getCachePath(), "utf8"));
9094
+ } catch {
9095
+ return null;
9096
+ }
9097
+ }
9098
+ function writeCache(entry) {
9099
+ try {
9100
+ mkdirSync(getCacheDir(), { recursive: true });
9101
+ writeFileSync(getCachePath(), JSON.stringify(entry));
9102
+ } catch {}
9103
+ }
9104
+ async function fetchLatestVersion(channel) {
9105
+ try {
7906
9106
  const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
7907
9107
  if (!res.ok) return null;
7908
9108
  const data = await res.json();
@@ -8021,6 +9221,19 @@ function resolveOrg(argv, config) {
8021
9221
  return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
8022
9222
  }
8023
9223
  /**
9224
+ * Print the dim version line once per terminal session — keyed by the
9225
+ * parent shell's PID, since that's stable across repeated bare `holocron`
9226
+ * invocations in the same shell but changes in a new tab/window. A marker
9227
+ * file in the OS tmpdir tracks whether this shell has already seen it, so
9228
+ * re-running `holocron` doesn't repeat the line every time.
9229
+ */
9230
+ function printVersionHeaderOnce() {
9231
+ const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
9232
+ if (existsSync(marker)) return;
9233
+ console.log(style.dim(`v${CLI_VERSION}`));
9234
+ writeFileSync(marker, "");
9235
+ }
9236
+ /**
8024
9237
  * `buildCliLogger` options derived from a loaded `holocron.config` — the
8025
9238
  * level, the Axiom dataset, and the resolved org for a keyring-backed
8026
9239
  * Axiom token. Used by every handler that has already called `loadConfig`.
@@ -8050,23 +9263,31 @@ function tokenContext(rawTokens) {
8050
9263
  return parseTokenArgs(rawTokens);
8051
9264
  } catch (err) {
8052
9265
  if (err instanceof TokenParseError) {
8053
- getLogger().error(`--token: ${err.message}`);
9266
+ reportError(`--token: ${err.message}`);
8054
9267
  process.exitCode = 1;
8055
9268
  return null;
8056
9269
  }
8057
9270
  throw err;
8058
9271
  }
8059
9272
  }
9273
+ /** Layer 2: a bare group command (`holocron repo`, `holocron repo upgrade`, …) opens the menu at that level. */
9274
+ async function launchGroupMenu(parent, argv) {
9275
+ await launchMenu(COMMAND_REGISTRY, argv, `${parent} — choose a subcommand:`, `Run \`holocron ${parent} --help\` to see available ${parent} subcommands.`, parent.split(" "));
9276
+ }
8060
9277
  init(CLI_VERSION);
8061
9278
  const updateCheckPromise = checkForUpdates(CLI_VERSION);
8062
9279
  let finishCommand = () => {};
8063
9280
  /** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
8064
9281
  let errorReported = false;
8065
9282
  try {
8066
- await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
9283
+ await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").version(CLI_VERSION).alias("version", "v").parserConfiguration({ "populate--": true }).option("dry-run", {
8067
9284
  type: "boolean",
8068
9285
  default: false,
8069
9286
  describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
9287
+ }).option("skip-dry-run", {
9288
+ type: "boolean",
9289
+ default: false,
9290
+ describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
8070
9291
  }).option("token", {
8071
9292
  type: "string",
8072
9293
  array: true,
@@ -8090,20 +9311,21 @@ try {
8090
9311
  type: "boolean",
8091
9312
  default: false,
8092
9313
  describe: "Set the log level to error — suppress info and warn."
9314
+ }).option("log", {
9315
+ type: "string",
9316
+ choices: LOG_LEVELS,
9317
+ describe: "Show structured logger output at this level. Local/interactive runs are quiet by default (no console output regardless of level) — this opts back in."
8093
9318
  }).middleware((argv) => {
8094
- const name = argv._.slice(0, 2).join(" ") || "unknown";
9319
+ const name = argv._.slice(0, 3).join(" ") || "unknown";
8095
9320
  printRunId = Boolean(argv.debug || argv.verbose);
8096
9321
  buildCliLogger(argv, {
8097
9322
  command: name,
8098
9323
  org: argv.org
8099
9324
  });
8100
9325
  finishCommand = startCommand(name);
8101
- }).command("version", "Print the CLI version", () => {}, () => {
8102
- console.log(`holocron ${CLI_VERSION}`);
8103
- }).command("clone", "Clone all repos in a GitHub org as siblings under a single directory", (y) => y.option("org", {
9326
+ }).command("clone [target]", "Clone a single repo (org/repo) or every repo in an org (org)", (y) => y.positional("target", {
8104
9327
  type: "string",
8105
- demandOption: true,
8106
- describe: "GitHub org to clone (e.g., theholocron)"
9328
+ describe: "org/repo to clone, or a bare org to clone every repo in it (confirms first)."
8107
9329
  }).option("dir", {
8108
9330
  type: "string",
8109
9331
  describe: "Parent directory to clone into (default: ~/Code/<org>)"
@@ -8114,16 +9336,39 @@ try {
8114
9336
  try {
8115
9337
  token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
8116
9338
  } catch (err) {
8117
- getLogger().error(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
9339
+ reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8118
9340
  process.exitCode = 1;
8119
9341
  return;
8120
9342
  }
8121
- if ((await runClone({
8122
- org: argv.org,
9343
+ const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
9344
+ const { org, repo } = parseCloneTarget(targetInput);
9345
+ const report = await runClone({
9346
+ org,
9347
+ ...repo ? { repo } : {},
8123
9348
  token,
8124
9349
  dryRun: argv.dryRun,
8125
- ...argv.dir ? { dir: argv.dir } : {}
8126
- })).status === "fail") process.exitCode = 1;
9350
+ ...argv.dir ? { dir: argv.dir } : {},
9351
+ ...!repo ? { confirmWholeOrg: async ({ total, alreadyCloned, toClone }) => {
9352
+ if (!process.stdin.isTTY) throw new NonInteractiveError(`clone: cloning every repo in "${org}" needs interactive confirmation — run this from a terminal, or pass a specific org/repo instead`);
9353
+ if (toClone === 0) return true;
9354
+ return confirm({
9355
+ message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
9356
+ default: false
9357
+ });
9358
+ } } : {},
9359
+ confirmOpen: async (openTarget, moreToCome) => {
9360
+ if (!process.stdin.isTTY) return;
9361
+ if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
9362
+ if (await confirm({
9363
+ message: `Open ${openTarget}?`,
9364
+ default: false
9365
+ })) openPath(openTarget);
9366
+ }
9367
+ });
9368
+ if (report.status === "fail") {
9369
+ if (report.message) reportError(`clone: ${report.message}`);
9370
+ process.exitCode = 1;
9371
+ } else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
8127
9372
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
8128
9373
  type: "string",
8129
9374
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -8142,28 +9387,6 @@ try {
8142
9387
  org: resolveOrg(argv, loaded.resolved)
8143
9388
  }
8144
9389
  })).summary.fail > 0) process.exitCode = 1;
8145
- }).command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
8146
- type: "string",
8147
- describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
8148
- }).option("hooks", {
8149
- type: "boolean",
8150
- describe: "Install git hooks (.husky/pre-push → holocron ci). Default: on for protection:'strict'. --no-hooks to skip."
8151
- }), async (argv) => {
8152
- const tokens = tokenContext(argv.token);
8153
- if (!tokens) return;
8154
- const loaded = await loadConfig(argv.cwd);
8155
- applyResolvedConfig(argv, loaded.resolved);
8156
- if ((await runSetup({
8157
- loaded,
8158
- context: {
8159
- repoRoot: argv.cwd,
8160
- dryRun: argv.dryRun,
8161
- ...argv.repo ? { repo: argv.repo } : {},
8162
- ...tokens,
8163
- org: resolveOrg(argv, loaded.resolved)
8164
- },
8165
- ...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
8166
- })).summary.fail > 0) process.exitCode = 1;
8167
9390
  }).command("skills", "Manage agent skills from the @theholocron/skills registry", (y) => y.command("install", "Copy skills from @theholocron/skills into .agents/ with agent symlinks", () => {}, async (argv) => {
8168
9391
  await runSkillsInstall({
8169
9392
  loaded: await loadConfig(argv.cwd),
@@ -8196,331 +9419,214 @@ try {
8196
9419
  ...argv.name ? { name: argv.name } : {}
8197
9420
  }).status === "fail") process.exitCode = 1;
8198
9421
  }).command("$0", false, () => {}, async (argv) => {
8199
- await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "skills"), argv, "skills — choose a subcommand:", "Run `holocron skills --help` to see available skills subcommands.");
8200
- }), () => {}).command("secret set [name] [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
9422
+ await launchGroupMenu("skills", argv);
9423
+ }), () => {}).command("run <task> [job] [passthrough..]", "Run a task locally (test, typecheck, lint, build, audit) — figures out turbo / the tool / the package manager. `run ci` runs every merge-gating check; `run lint commit-msg <file>` lints a commit message.", (y) => y.positional("task", {
8201
9424
  type: "string",
8202
- describe: "Secret name (e.g., NPM_TOKEN)"
8203
- }).positional("value", {
9425
+ demandOption: true,
9426
+ describe: "Task name (test, typecheck, lint, build, audit, or `ci` for the merge-gating suite). See `holocron run --help`."
9427
+ }).positional("job", {
8204
9428
  type: "string",
8205
- describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
8206
- }).option("from-stdin", {
9429
+ describe: "Sub-job within the task (e.g. `holocron run audit performance`, `holocron run lint commit-msg <file>`). Omit to run every job."
9430
+ }).positional("passthrough", {
9431
+ type: "string",
9432
+ array: true,
9433
+ describe: "Args forwarded to the tool — put them after `--`."
9434
+ }).option("required", {
8207
9435
  type: "boolean",
8208
9436
  default: false,
8209
- describe: "Read the secret value from stdin"
8210
- }).option("from-env", {
9437
+ describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
9438
+ }).option("all", {
9439
+ type: "boolean",
9440
+ default: false,
9441
+ describe: "With `run ci`: run every `ci: true` task, not just the required ones."
9442
+ }).option("filter", {
8211
9443
  type: "string",
8212
- describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
8213
- }).option("scope", {
9444
+ describe: "turbo --filter=<pkg> passthrough (monorepo)."
9445
+ }), async (argv) => {
9446
+ const { logger } = buildCliLogger(argv, { command: argv.task === "ci" ? "run ci" : argv.task === "lint" && argv.job === "commit-msg" ? "run lint commit-msg" : "run" });
9447
+ const passthrough = [...argv.passthrough ?? [], ...argv["--"] ?? []];
9448
+ if (argv.task === "lint" && argv.job === "commit-msg") {
9449
+ const file = passthrough[0];
9450
+ if (!file) {
9451
+ reportError("run lint commit-msg: missing <file> (the commit message file git passes as $1).");
9452
+ process.exitCode = 1;
9453
+ return;
9454
+ }
9455
+ const result = await lintCommitMsgFile(file, argv.cwd);
9456
+ if (!result.valid) {
9457
+ for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
9458
+ process.exitCode = 1;
9459
+ }
9460
+ return;
9461
+ }
9462
+ if (argv.task === "ci") {
9463
+ const ciConfig = await loadTasksConfig(argv.cwd).catch(() => void 0);
9464
+ if (createAstromech({
9465
+ cwd: argv.cwd,
9466
+ logger,
9467
+ config: ciConfig
9468
+ }).ci({
9469
+ dryRun: argv.dryRun,
9470
+ scope: argv.all ? "all" : "required",
9471
+ ...argv.filter ? { filter: argv.filter } : {}
9472
+ }).status === "fail") process.exitCode = 1;
9473
+ return;
9474
+ }
9475
+ const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
9476
+ const report = createAstromech({
9477
+ cwd: argv.cwd,
9478
+ logger,
9479
+ config
9480
+ }).run(argv.task, {
9481
+ ...argv.job !== void 0 ? { job: argv.job } : {},
9482
+ passthrough,
9483
+ dryRun: argv.dryRun,
9484
+ required: argv.required,
9485
+ ...argv.filter ? { filter: argv.filter } : {}
9486
+ });
9487
+ if (report.status === "fail" || report.status === "unknown") process.exitCode = 1;
9488
+ }).command("config show", "Print the resolved holocron config", (y) => y.option("depth", {
9489
+ type: "number",
9490
+ describe: "Collapse objects/arrays nested deeper than N levels (TTY output only)"
9491
+ }).option("interactive", {
9492
+ type: "boolean",
9493
+ describe: "Browse the config in a foldable tree (default on a TTY; --no-interactive for static output)"
9494
+ }), async (argv) => {
9495
+ await viewJson((await loadConfig(argv.cwd)).resolved, {
9496
+ depth: argv.depth,
9497
+ interactive: argv.interactive
9498
+ });
9499
+ }).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", { type: "string" }).positional("value", { type: "string" }), async (argv) => {
9500
+ const [provider] = await promptForPositionals(getEntry("auth set"), argv);
9501
+ if ((await runAuthSet({
9502
+ provider,
9503
+ ...argv.value ? { positional: argv.value } : {},
9504
+ ...argv.org ? { org: argv.org } : {}
9505
+ })).status === "fail") process.exitCode = 1;
9506
+ }).command("unset [provider]", "Remove a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
9507
+ const [provider] = await promptForPositionals(getEntry("auth unset"), argv);
9508
+ runAuthUnset({ provider });
9509
+ }).command("check [provider]", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
9510
+ const [provider] = await promptForPositionals(getEntry("auth check"), argv);
9511
+ if ((await runAuthCheck({
9512
+ provider,
9513
+ ...argv.org ? { org: argv.org } : {}
9514
+ })).status === "fail") process.exitCode = 1;
9515
+ }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
9516
+ await runAuthList();
9517
+ }).command("$0", false, () => {}, async (argv) => {
9518
+ await launchGroupMenu("auth", argv);
9519
+ }), () => {}).command("$0", false, () => {}, async (argv) => {
9520
+ printVersionHeaderOnce();
9521
+ const runEntries = buildRunMenuEntries(await listRunnableTasks(argv));
9522
+ await launchMenu([...COMMAND_REGISTRY, ...runEntries], argv);
9523
+ }).command("repo", "Set up, sync and upgrade the current repo", (g) => g.command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
8214
9524
  type: "string",
8215
- default: "repo",
8216
- describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
9525
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
9526
+ }).option("hooks", {
9527
+ type: "boolean",
9528
+ describe: "Install git hooks (.husky/pre-push → holocron run ci). Default: on for protection:'strict'. --no-hooks to skip."
8217
9529
  }), async (argv) => {
8218
9530
  const tokens = tokenContext(argv.token);
8219
9531
  if (!tokens) return;
8220
- const [name] = await promptForPositionals(getEntry("secret set"), argv);
8221
- const scopeArg = argv.scope;
8222
- const scope = parseScope(scopeArg);
8223
9532
  const loaded = await loadConfig(argv.cwd);
8224
9533
  applyResolvedConfig(argv, loaded.resolved);
8225
- if ((await runSecretSet({
9534
+ if ((await runSetup({
8226
9535
  loaded,
8227
9536
  context: {
8228
9537
  repoRoot: argv.cwd,
8229
9538
  dryRun: argv.dryRun,
9539
+ ...argv.repo ? { repo: argv.repo } : {},
8230
9540
  ...tokens,
8231
9541
  org: resolveOrg(argv, loaded.resolved)
8232
9542
  },
8233
- name,
8234
- ...argv.value ? { value: argv.value } : {},
8235
- ...argv.fromStdin ? { fromStdin: true } : {},
8236
- ...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
8237
- scope
8238
- })).status === "fail") process.exitCode = 1;
8239
- }).command("secrets sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
8240
- type: "string",
8241
- describe: "Vault environment id to read (1P Environment id, etc.)"
8242
- }).option("project-id", {
8243
- type: "string",
8244
- describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
8245
- }).option("target", {
8246
- type: "array",
8247
- default: ["production", "preview"],
8248
- describe: "Deployment targets to sync to. Defaults to production + preview."
9543
+ ...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
9544
+ })).summary.fail > 0) process.exitCode = 1;
9545
+ }).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.command("readme", "Sync the Installation + Usage block in README.md from package.json", (y) => y.option("dry-run", {
9546
+ type: "boolean",
9547
+ describe: "Print what would change without writing",
9548
+ default: false
8249
9549
  }), async (argv) => {
8250
- const tokens = tokenContext(argv.token);
8251
- if (!tokens) return;
8252
- const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
8253
9550
  const loaded = await loadConfig(argv.cwd);
8254
9551
  applyResolvedConfig(argv, loaded.resolved);
8255
- if ((await runSecretsSync({
9552
+ const report = await runSyncReadme({
8256
9553
  loaded,
8257
9554
  context: {
8258
9555
  repoRoot: argv.cwd,
8259
- dryRun: argv.dryRun,
8260
- ...tokens,
8261
- org: resolveOrg(argv, loaded.resolved)
8262
- },
8263
- environmentId,
8264
- ...argv.projectId ? { projectId: argv.projectId } : {},
8265
- targets: argv.target
8266
- })).summary.fail > 0) process.exitCode = 1;
8267
- }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
8268
- type: "string",
8269
- describe: "Git branch to deploy"
8270
- }).option("project-id", {
9556
+ dryRun: argv.dryRun
9557
+ }
9558
+ });
9559
+ if (report.status === "fail") {
9560
+ if (report.message) reportError(`repo sync readme: ${report.message}`);
9561
+ process.exitCode = 1;
9562
+ }
9563
+ }).positional("steps", {
8271
9564
  type: "string",
8272
- demandOption: true,
8273
- describe: "Deployment project id (e.g., Vercel prj_*)"
8274
- }).option("target", {
9565
+ array: true,
9566
+ describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
9567
+ }).option("repo", {
8275
9568
  type: "string",
8276
- choices: ["production", "staging"],
8277
- describe: "Named environment to deploy into. Omit for a branch preview."
9569
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
8278
9570
  }), async (argv) => {
8279
9571
  const tokens = tokenContext(argv.token);
8280
9572
  if (!tokens) return;
8281
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8282
9573
  const loaded = await loadConfig(argv.cwd);
8283
9574
  applyResolvedConfig(argv, loaded.resolved);
8284
- if ((await runDeploy({
9575
+ if ((await runSync({
8285
9576
  loaded,
8286
9577
  context: {
8287
9578
  repoRoot: argv.cwd,
8288
9579
  dryRun: argv.dryRun,
9580
+ ...argv.repo ? { repo: argv.repo } : {},
8289
9581
  ...tokens,
8290
9582
  org: resolveOrg(argv, loaded.resolved)
8291
9583
  },
8292
- projectId: argv.projectId,
8293
- branch,
8294
- ...argv.target ? { target: argv.target } : {}
8295
- })).status === "fail") process.exitCode = 1;
8296
- }).command("cleanup-preview [pr]", "List and delete Cloudflare Pages preview deployments for a GitHub PR", (y) => y.positional("pr", {
9584
+ ...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
9585
+ })).summary.fail > 0) process.exitCode = 1;
9586
+ }).command("upgrade", "Upgrade toolchain version pins across the repo", (y) => y.command("node [to]", "Scan the repo and update every Node.js version pin to a new major", (yy) => yy.positional("to", {
8297
9587
  type: "number",
8298
- describe: "PR number to clean up"
8299
- }).option("project", {
8300
- type: "string",
8301
- demandOption: true,
8302
- describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
8303
- }).option("repo", {
8304
- type: "string",
8305
- describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
8306
- }), async (argv) => {
8307
- const tokens = tokenContext(argv.token);
8308
- if (!tokens) return;
8309
- const [pr] = await promptForPositionals(getEntry("cleanup-preview"), argv);
8310
- const loaded = await loadConfig(argv.cwd);
8311
- applyResolvedConfig(argv, loaded.resolved);
8312
- if ((await runCleanupPreview({
8313
- loaded,
8314
- context: {
8315
- repoRoot: argv.cwd,
8316
- dryRun: argv.dryRun,
8317
- ...tokens,
8318
- org: resolveOrg(argv, loaded.resolved)
8319
- },
8320
- prNumber: Number(pr),
8321
- project: argv.project,
8322
- ...argv.repo ? { repo: argv.repo } : {}
8323
- })).status === "fail") process.exitCode = 1;
8324
- }).command("bump-versions [new-version]", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (y) => y.positional("new-version", {
8325
- type: "string",
8326
- describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
8327
- }), async (argv) => {
8328
- const [newVersion] = await promptForPositionals(getEntry("bump-versions"), argv);
8329
- if ((await runNpmBumpVersions({
8330
- version: newVersion,
8331
- cwd: argv.cwd,
8332
- dryRun: argv.dryRun
8333
- })).status === "fail") process.exitCode = 1;
8334
- }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
8335
- type: "boolean",
8336
- default: false,
8337
- describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Skips the steady-state OIDC path below — use only for a package's very first publish."
8338
- }).option("tag", {
8339
- type: "string",
8340
- describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
8341
- }).option("otp", {
8342
- type: "string",
8343
- describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
8344
- }).option("sync-trust", {
8345
- type: "array",
8346
- describe: "Migrate npm Trusted Publisher config to a new workflow filename across every public package: --sync-trust <old-file> <new-file>. See issue #689 — npm requires fresh interactive 2FA per package; re-run after approving the printed authUrl if it stops early."
8347
- }).option("repo", {
8348
- type: "string",
8349
- describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
8350
- }).option("provenance", {
8351
- type: "boolean",
8352
- default: true,
8353
- describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
8354
- }).option("skip-already-published", {
8355
- type: "boolean",
8356
- default: false,
8357
- describe: "Steady-state only: check `npm view <pkg>@<version>` before publishing each package and skip it if that exact version already exists, instead of one bulk publish. For repos that ship many independently-versioned packages where a release doesn't bump every one."
8358
- }), async (argv) => {
8359
- if (argv.syncTrust) {
8360
- const [oldFile, newFile] = argv.syncTrust;
8361
- if (!oldFile || !newFile) {
8362
- getLogger().error("publish --sync-trust requires exactly two values: <old-file> <new-file>");
8363
- process.exitCode = 1;
8364
- return;
8365
- }
8366
- if ((await runSyncTrust({
8367
- cwd: argv.cwd,
8368
- oldFile,
8369
- newFile,
8370
- dryRun: argv.dryRun,
8371
- ...argv.repo ? { repo: argv.repo } : {}
8372
- })).status === "fail") process.exitCode = 1;
8373
- return;
8374
- }
8375
- if (argv.initial) {
8376
- if ((await runPublish({
8377
- cwd: argv.cwd,
8378
- dryRun: argv.dryRun,
8379
- ...argv.tag ? { tag: argv.tag } : {},
8380
- ...argv.otp ? { otp: argv.otp } : {}
8381
- })).status === "fail") process.exitCode = 1;
8382
- return;
8383
- }
8384
- if ((await runSteadyPublish({
8385
- cwd: argv.cwd,
8386
- dryRun: argv.dryRun,
8387
- provenance: argv.provenance,
8388
- skipAlreadyPublished: argv.skipAlreadyPublished,
8389
- ...argv.tag ? { tag: argv.tag } : {},
8390
- ...argv.otp ? { otp: argv.otp } : {}
8391
- })).status === "fail") process.exitCode = 1;
8392
- }).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {
8393
- type: "string",
8394
- array: true,
8395
- describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
8396
- }).option("repo", {
8397
- type: "string",
8398
- describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
8399
- }), async (argv) => {
8400
- const tokens = tokenContext(argv.token);
8401
- if (!tokens) return;
8402
- const loaded = await loadConfig(argv.cwd);
8403
- applyResolvedConfig(argv, loaded.resolved);
8404
- if ((await runSync({
8405
- loaded,
8406
- context: {
8407
- repoRoot: argv.cwd,
8408
- dryRun: argv.dryRun,
8409
- ...argv.repo ? { repo: argv.repo } : {},
8410
- ...tokens,
8411
- org: resolveOrg(argv, loaded.resolved)
8412
- },
8413
- ...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
8414
- })).summary.fail > 0) process.exitCode = 1;
8415
- }).command("run <task> [job] [passthrough..]", "Run a task locally (test, typecheck, lint, build, audit) — figures out turbo / the tool / the package manager", (y) => y.positional("task", {
8416
- type: "string",
8417
- demandOption: true,
8418
- describe: "Task name (test, typecheck, lint, build, audit). See `holocron run --help`."
8419
- }).positional("job", {
8420
- type: "string",
8421
- describe: "Sub-job within the task (e.g. `holocron run audit performance`). Omit to run every job. Only `audit` has jobs today."
8422
- }).positional("passthrough", {
8423
- type: "string",
8424
- array: true,
8425
- describe: "Args forwarded to the tool — put them after `--`."
8426
- }).option("required", {
8427
- type: "boolean",
8428
- default: false,
8429
- describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
8430
- }).option("filter", {
8431
- type: "string",
8432
- describe: "turbo --filter=<pkg> passthrough (monorepo)."
9588
+ describe: "Target Node.js major version (e.g., 22)"
9589
+ }).option("from", {
9590
+ type: "number",
9591
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
8433
9592
  }), async (argv) => {
8434
- const { logger } = buildCliLogger(argv, { command: "run" });
8435
- const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
8436
- const report = createAstromech({
9593
+ const [to] = await promptForPositionals(getEntry("repo upgrade node"), argv);
9594
+ let extra = [];
9595
+ try {
9596
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
9597
+ const extraRaw = JSON.parse(raw).upgrade?.node?.extra;
9598
+ if (Array.isArray(extraRaw)) extra = extraRaw;
9599
+ } catch {}
9600
+ const report = await runUpgradeNode({
9601
+ to: Number(to),
9602
+ ...argv.from != null ? { from: argv.from } : {},
8437
9603
  cwd: argv.cwd,
8438
- logger,
8439
- config
8440
- }).run(argv.task, {
8441
- ...argv.job !== void 0 ? { job: argv.job } : {},
8442
- passthrough: [...argv.passthrough ?? [], ...argv["--"] ?? []],
8443
9604
  dryRun: argv.dryRun,
8444
- required: argv.required,
8445
- ...argv.filter ? { filter: argv.filter } : {}
9605
+ extra
8446
9606
  });
8447
- if (report.status === "fail" || report.status === "unknown") process.exitCode = 1;
8448
- }).command("ci", "Run the merge-gating checks locally, in CI order — 'will CI pass?'", (y) => y.option("all", {
9607
+ if (report.status === "fail") {
9608
+ if (report.message) reportError(`upgrade node: ${report.message}`);
9609
+ process.exitCode = 1;
9610
+ }
9611
+ }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
8449
9612
  type: "boolean",
8450
9613
  default: false,
8451
- describe: "Run every `ci: true` task, not just the required ones."
8452
- }).option("filter", {
8453
- type: "string",
8454
- describe: "turbo --filter=<pkg> passthrough (monorepo)."
9614
+ describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
8455
9615
  }), async (argv) => {
8456
- const { logger } = buildCliLogger(argv, { command: "ci" });
8457
- const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
8458
- if (createAstromech({
9616
+ const report = await runUpgradeDeps({
8459
9617
  cwd: argv.cwd,
8460
- logger,
8461
- config
8462
- }).ci({
8463
9618
  dryRun: argv.dryRun,
8464
- scope: argv.all ? "all" : "required",
8465
- ...argv.filter ? { filter: argv.filter } : {}
8466
- }).status === "fail") process.exitCode = 1;
8467
- }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8468
- type: "string",
8469
- default: "theholocron/.github",
8470
- describe: "Target org/repo (default: theholocron/.github)"
8471
- }).option("branch", {
8472
- type: "string",
8473
- describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
8474
- }).option("pr", {
8475
- type: "boolean",
8476
- default: false,
8477
- describe: "Open a PR after pushing to --branch (no-op without --branch)"
8478
- }).option("message", {
8479
- type: "string",
8480
- describe: "Commit message (default: chore: sync from theholocron/holocron)"
8481
- }).option("output-dir", {
8482
- type: "string",
8483
- describe: "Write generated files to this local directory instead of pushing (for validation)"
8484
- }), async (argv) => {
8485
- const outputDir = argv["output-dir"];
8486
- const parsed = tokenContext(argv.token);
8487
- if (!parsed) return;
8488
- let token;
8489
- if (outputDir) token = "no-token-needed";
8490
- else try {
8491
- token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
8492
- } catch (err) {
8493
- getLogger().error(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9619
+ pinsOnly: argv.pinsOnly
9620
+ });
9621
+ if (report.status === "fail") {
9622
+ if (report.message) reportError(`upgrade deps: ${report.message}`);
8494
9623
  process.exitCode = 1;
8495
- return;
8496
9624
  }
8497
- if ((await runSyncGithub({
8498
- token,
8499
- repo: argv.repo,
8500
- dryRun: argv.dryRun,
8501
- ...argv.branch ? { branch: argv.branch } : {},
8502
- ...argv.pr ? { createPr: true } : {},
8503
- ...argv.message ? { message: argv.message } : {},
8504
- ...outputDir ? { outputDir } : {}
8505
- })).status === "fail") process.exitCode = 1;
8506
- }).command("sync-readme", "Sync the Installation + Usage block in README.md from package.json", (y) => y.option("dry-run", {
8507
- type: "boolean",
8508
- describe: "Print what would change without writing",
8509
- default: false
8510
- }), async (argv) => {
8511
- const loaded = await loadConfig(argv.cwd);
8512
- applyResolvedConfig(argv, loaded.resolved);
8513
- if ((await runSyncReadme({
8514
- loaded,
8515
- context: {
8516
- repoRoot: argv.cwd,
8517
- dryRun: argv.dryRun
8518
- }
8519
- })).status === "fail") process.exitCode = 1;
8520
- }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
8521
- const loaded = await loadConfig(argv.cwd);
8522
- console.log(JSON.stringify(loaded.resolved, null, 2));
8523
- }).command("new [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
9625
+ }).command("$0", false, () => {}, async (argv) => {
9626
+ await launchGroupMenu("repo upgrade", argv);
9627
+ }), () => {}).command("$0", false, () => {}, async (argv) => {
9628
+ await launchGroupMenu("repo", argv);
9629
+ }), () => {}).command("new", "Scaffold a new repo or plugin", (g) => g.command("repo [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
8524
9630
  type: "string",
8525
9631
  describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
8526
9632
  }).positional("name", {
@@ -8569,7 +9675,7 @@ try {
8569
9675
  }).option("verify", {
8570
9676
  type: "boolean",
8571
9677
  default: true,
8572
- describe: "Run pnpm install + holocron setup after bootstrapping (--no-verify skips)"
9678
+ describe: "Run pnpm install + holocron repo setup after bootstrapping (--no-verify skips)"
8573
9679
  }), async (argv) => {
8574
9680
  try {
8575
9681
  const tokens = tokenContext(argv.token);
@@ -8745,12 +9851,12 @@ try {
8745
9851
  }) === "yes";
8746
9852
  if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
8747
9853
  if (!type) {
8748
- getLogger().error("new: template type is required");
9854
+ reportError("new: template type is required");
8749
9855
  process.exitCode = 1;
8750
9856
  return;
8751
9857
  }
8752
9858
  if (!name) {
8753
- getLogger().error("new: repo name is required");
9859
+ reportError("new: repo name is required");
8754
9860
  process.exitCode = 1;
8755
9861
  return;
8756
9862
  }
@@ -8779,13 +9885,13 @@ try {
8779
9885
  })).status === "fail") process.exitCode = 1;
8780
9886
  } catch (err) {
8781
9887
  if (err instanceof NewError) {
8782
- getLogger().error(`new: ${err.message}`);
9888
+ reportError(`new: ${err.message}`);
8783
9889
  process.exitCode = 1;
8784
9890
  return;
8785
9891
  }
8786
9892
  throw err;
8787
9893
  }
8788
- }).command("plugin create [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
9894
+ }).command("plugin [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
8789
9895
  type: "string",
8790
9896
  describe: "Package slug (kebab-case)"
8791
9897
  }).positional("vendor", {
@@ -8809,7 +9915,7 @@ try {
8809
9915
  describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
8810
9916
  }), async (argv) => {
8811
9917
  try {
8812
- const [slug, vendor] = await promptForPositionals(getEntry("plugin create"), argv);
9918
+ const [slug, vendor] = await promptForPositionals(getEntry("new plugin"), argv);
8813
9919
  const { capability, vendorEnv, baseUrl } = await resolvePluginCreateInputs({
8814
9920
  capability: argv.capability,
8815
9921
  vendorEnv: argv.vendorEnv,
@@ -8838,92 +9944,315 @@ try {
8838
9944
  }).status === "fail") process.exitCode = 1;
8839
9945
  } catch (err) {
8840
9946
  if (err instanceof PluginCreateError) {
8841
- getLogger().error(`plugin create: ${err.message}`);
9947
+ reportError(`plugin create: ${err.message}`);
8842
9948
  process.exitCode = 1;
8843
9949
  return;
8844
9950
  }
8845
9951
  throw err;
8846
9952
  }
8847
- }).command("upgrade", "Upgrade toolchain version pins across the repo", (y) => y.command("node [to]", "Scan the repo and update every Node.js version pin to a new major", (yy) => yy.positional("to", {
8848
- type: "number",
8849
- describe: "Target Node.js major version (e.g., 22)"
8850
- }).option("from", {
8851
- type: "number",
8852
- describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
9953
+ }).command("$0", false, () => {}, async (argv) => {
9954
+ await launchGroupMenu("new", argv);
9955
+ }), () => {}).command("secrets", "Set secrets and sync vault environments", (g) => g.command("set [name] [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
9956
+ type: "string",
9957
+ describe: "Secret name (e.g., NPM_TOKEN)"
9958
+ }).positional("value", {
9959
+ type: "string",
9960
+ describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
9961
+ }).option("from-stdin", {
9962
+ type: "boolean",
9963
+ default: false,
9964
+ describe: "Read the secret value from stdin"
9965
+ }).option("from-env", {
9966
+ type: "string",
9967
+ describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
9968
+ }).option("scope", {
9969
+ type: "string",
9970
+ default: "repo",
9971
+ describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
8853
9972
  }), async (argv) => {
8854
- const [to] = await promptForPositionals(getEntry("upgrade node"), argv);
8855
- let extra = [];
8856
- try {
8857
- const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
8858
- const extraRaw = JSON.parse(raw).upgrade?.node?.extra;
8859
- if (Array.isArray(extraRaw)) extra = extraRaw;
8860
- } catch {}
8861
- const report = await runUpgradeNode({
8862
- to: Number(to),
8863
- ...argv.from != null ? { from: argv.from } : {},
8864
- cwd: argv.cwd,
8865
- dryRun: argv.dryRun,
8866
- extra
8867
- });
8868
- if (report.status === "fail") {
8869
- if (report.message) getLogger().error(`upgrade node: ${report.message}`);
9973
+ const tokens = tokenContext(argv.token);
9974
+ if (!tokens) return;
9975
+ const [name] = await promptForPositionals(getEntry("secrets set"), argv);
9976
+ const scopeArg = argv.scope;
9977
+ const scope = parseScope(scopeArg);
9978
+ const loaded = await loadConfig(argv.cwd);
9979
+ applyResolvedConfig(argv, loaded.resolved);
9980
+ if ((await runSecretSet({
9981
+ loaded,
9982
+ context: {
9983
+ repoRoot: argv.cwd,
9984
+ dryRun: argv.dryRun,
9985
+ ...tokens,
9986
+ org: resolveOrg(argv, loaded.resolved)
9987
+ },
9988
+ name,
9989
+ ...argv.value ? { value: argv.value } : {},
9990
+ ...argv.fromStdin ? { fromStdin: true } : {},
9991
+ ...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
9992
+ scope
9993
+ })).status === "fail") process.exitCode = 1;
9994
+ }).command("sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
9995
+ type: "string",
9996
+ describe: "Vault environment id to read (1P Environment id, etc.)"
9997
+ }).option("project-id", {
9998
+ type: "string",
9999
+ describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
10000
+ }).option("target", {
10001
+ type: "array",
10002
+ default: ["production", "preview"],
10003
+ describe: "Deployment targets to sync to. Defaults to production + preview."
10004
+ }).option("github-secret", {
10005
+ type: "array",
10006
+ describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
10007
+ }).option("github-secret-scope", {
10008
+ type: "string",
10009
+ default: "repo",
10010
+ describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
10011
+ }), async (argv) => {
10012
+ const tokens = tokenContext(argv.token);
10013
+ if (!tokens) return;
10014
+ const githubSecretScope = parseScope(argv.githubSecretScope);
10015
+ const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
10016
+ const loaded = await loadConfig(argv.cwd);
10017
+ applyResolvedConfig(argv, loaded.resolved);
10018
+ if ((await runSecretsSync({
10019
+ loaded,
10020
+ context: {
10021
+ repoRoot: argv.cwd,
10022
+ dryRun: argv.dryRun,
10023
+ ...tokens,
10024
+ org: resolveOrg(argv, loaded.resolved)
10025
+ },
10026
+ environmentId,
10027
+ ...argv.projectId ? { projectId: argv.projectId } : {},
10028
+ ...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
10029
+ githubSecretScope,
10030
+ targets: argv.target
10031
+ })).summary.fail > 0) process.exitCode = 1;
10032
+ }).command("$0", false, () => {}, async (argv) => {
10033
+ await launchGroupMenu("secrets", argv);
10034
+ }), () => {}).command("sync", "Sync generated files to other repos", (g) => g.command("github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
10035
+ type: "string",
10036
+ default: "theholocron/.github",
10037
+ describe: "Target org/repo (default: theholocron/.github)"
10038
+ }).option("branch", {
10039
+ type: "string",
10040
+ describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
10041
+ }).option("pr", {
10042
+ type: "boolean",
10043
+ default: false,
10044
+ describe: "Open a PR after pushing to --branch (no-op without --branch)"
10045
+ }).option("message", {
10046
+ type: "string",
10047
+ describe: "Commit message (default: chore: sync from theholocron/holocron)"
10048
+ }).option("output-dir", {
10049
+ type: "string",
10050
+ describe: "Write generated files to this local directory instead of pushing (for validation)"
10051
+ }), async (argv) => {
10052
+ const outputDir = argv["output-dir"];
10053
+ const parsed = tokenContext(argv.token);
10054
+ if (!parsed) return;
10055
+ let token;
10056
+ if (outputDir) token = "no-token-needed";
10057
+ else try {
10058
+ token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
10059
+ } catch (err) {
10060
+ reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
8870
10061
  process.exitCode = 1;
10062
+ return;
8871
10063
  }
8872
- }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
10064
+ if ((await runSyncGithub({
10065
+ token,
10066
+ repo: argv.repo,
10067
+ dryRun: argv.dryRun,
10068
+ ...argv.branch ? { branch: argv.branch } : {},
10069
+ ...argv.pr ? { createPr: true } : {},
10070
+ ...argv.message ? { message: argv.message } : {},
10071
+ ...outputDir ? { outputDir } : {}
10072
+ })).status === "fail") process.exitCode = 1;
10073
+ }).command("$0", false, () => {}, async (argv) => {
10074
+ await launchGroupMenu("sync", argv);
10075
+ }), () => {}).command("package", "Version and publish workspace packages", (g) => g.command("bump-versions [new-version]", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (y) => y.positional("new-version", {
10076
+ type: "string",
10077
+ describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
10078
+ }), async (argv) => {
10079
+ const [newVersion] = await promptForPositionals(getEntry("package bump-versions"), argv);
10080
+ if ((await runNpmBumpVersions({
10081
+ version: newVersion,
10082
+ cwd: argv.cwd,
10083
+ dryRun: argv.dryRun
10084
+ })).status === "fail") process.exitCode = 1;
10085
+ }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
8873
10086
  type: "boolean",
8874
10087
  default: false,
8875
- describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
10088
+ describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Skips the steady-state OIDC path below — use only for a package's very first publish."
10089
+ }).option("tag", {
10090
+ type: "string",
10091
+ describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
10092
+ }).option("otp", {
10093
+ type: "string",
10094
+ describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
10095
+ }).option("sync-trust", {
10096
+ type: "array",
10097
+ describe: "Migrate npm Trusted Publisher config to a new workflow filename across every public package: --sync-trust <old-file> <new-file>. See issue #689 — npm requires fresh interactive 2FA per package; re-run after approving the printed authUrl if it stops early."
10098
+ }).option("repo", {
10099
+ type: "string",
10100
+ describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
10101
+ }).option("provenance", {
10102
+ type: "boolean",
10103
+ default: true,
10104
+ describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
10105
+ }).option("skip-already-published", {
10106
+ type: "boolean",
10107
+ default: false,
10108
+ describe: "Steady-state only: check `npm view <pkg>@<version>` before publishing each package and skip it if that exact version already exists, instead of one bulk publish. For repos that ship many independently-versioned packages where a release doesn't bump every one."
8876
10109
  }), async (argv) => {
8877
- const report = await runUpgradeDeps({
10110
+ if (argv.syncTrust) {
10111
+ const [oldFile, newFile] = argv.syncTrust;
10112
+ if (!oldFile || !newFile) {
10113
+ reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
10114
+ process.exitCode = 1;
10115
+ return;
10116
+ }
10117
+ if ((await runSyncTrust({
10118
+ cwd: argv.cwd,
10119
+ oldFile,
10120
+ newFile,
10121
+ dryRun: argv.dryRun,
10122
+ ...argv.repo ? { repo: argv.repo } : {}
10123
+ })).status === "fail") process.exitCode = 1;
10124
+ return;
10125
+ }
10126
+ if (argv.initial) {
10127
+ if ((await runPublish({
10128
+ cwd: argv.cwd,
10129
+ dryRun: argv.dryRun,
10130
+ ...argv.tag ? { tag: argv.tag } : {},
10131
+ ...argv.otp ? { otp: argv.otp } : {}
10132
+ })).status === "fail") process.exitCode = 1;
10133
+ return;
10134
+ }
10135
+ if ((await runSteadyPublish({
8878
10136
  cwd: argv.cwd,
8879
10137
  dryRun: argv.dryRun,
8880
- pinsOnly: argv.pinsOnly
8881
- });
8882
- if (report.status === "fail") {
8883
- if (report.message) getLogger().error(`upgrade deps: ${report.message}`);
8884
- process.exitCode = 1;
8885
- }
10138
+ provenance: argv.provenance,
10139
+ skipAlreadyPublished: argv.skipAlreadyPublished,
10140
+ ...argv.tag ? { tag: argv.tag } : {},
10141
+ ...argv.otp ? { otp: argv.otp } : {}
10142
+ })).status === "fail") process.exitCode = 1;
8886
10143
  }).command("$0", false, () => {}, async (argv) => {
8887
- await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "upgrade"), argv, "upgrade — choose a subcommand:", "Run `holocron upgrade --help` to see available upgrade subcommands.");
8888
- }), () => {}).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", { type: "string" }).positional("value", { type: "string" }), async (argv) => {
8889
- const [provider] = await promptForPositionals(getEntry("auth set"), argv);
8890
- if ((await runAuthSet({
8891
- provider,
8892
- ...argv.value ? { positional: argv.value } : {},
8893
- ...argv.org ? { org: argv.org } : {}
10144
+ await launchGroupMenu("package", argv);
10145
+ }), () => {}).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.command("cleanup-preview [pr]", "List and delete Cloudflare Pages preview deployments for a GitHub PR", (y) => y.positional("pr", {
10146
+ type: "number",
10147
+ describe: "PR number to clean up"
10148
+ }).option("project", {
10149
+ type: "string",
10150
+ demandOption: true,
10151
+ describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
10152
+ }).option("repo", {
10153
+ type: "string",
10154
+ describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
10155
+ }), async (argv) => {
10156
+ const tokens = tokenContext(argv.token);
10157
+ if (!tokens) return;
10158
+ const [pr] = await promptForPositionals(getEntry("deploy cleanup-preview"), argv);
10159
+ const loaded = await loadConfig(argv.cwd);
10160
+ applyResolvedConfig(argv, loaded.resolved);
10161
+ if ((await runCleanupPreview({
10162
+ loaded,
10163
+ context: {
10164
+ repoRoot: argv.cwd,
10165
+ dryRun: argv.dryRun,
10166
+ ...tokens,
10167
+ org: resolveOrg(argv, loaded.resolved)
10168
+ },
10169
+ prNumber: Number(pr),
10170
+ project: argv.project,
10171
+ ...argv.repo ? { repo: argv.repo } : {}
8894
10172
  })).status === "fail") process.exitCode = 1;
8895
- }).command("unset [provider]", "Remove a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
8896
- const [provider] = await promptForPositionals(getEntry("auth unset"), argv);
8897
- runAuthUnset({ provider });
8898
- }).command("check [provider]", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
8899
- const [provider] = await promptForPositionals(getEntry("auth check"), argv);
8900
- if ((await runAuthCheck({
8901
- provider,
8902
- ...argv.org ? { org: argv.org } : {}
10173
+ }).command("on-release", "Deploy every workspace package whose delivery.deploy task is `on: release` — run by the delivery.publish workflow's deploy job", (y) => y.option("channel", {
10174
+ type: "string",
10175
+ default: "",
10176
+ describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
10177
+ }).option("from", {
10178
+ type: "string",
10179
+ describe: "The previous release's commit, to diff against. Omit when there is none."
10180
+ }).option("to", {
10181
+ type: "string",
10182
+ describe: "The release commit. Default HEAD."
10183
+ }), async (argv) => {
10184
+ if ((await deployOnRelease({
10185
+ cwd: argv.cwd,
10186
+ channel: argv.channel,
10187
+ ...argv.from ? { from: argv.from } : {},
10188
+ ...argv.to ? { to: argv.to } : {},
10189
+ dryRun: argv.dryRun,
10190
+ print: (line) => console.log(line)
10191
+ })).status === "fail") process.exitCode = 1;
10192
+ }).positional("branch", {
10193
+ type: "string",
10194
+ describe: "Git branch to deploy (omit when using --files)"
10195
+ }).option("project-id", {
10196
+ type: "string",
10197
+ describe: "Deployment project id (required to trigger a deployment, e.g. Vercel prj_*)"
10198
+ }).option("target", {
10199
+ type: "string",
10200
+ choices: ["production", "staging"],
10201
+ describe: "Named environment to deploy into. Omit for a branch preview."
10202
+ }).option("files", {
10203
+ type: "string",
10204
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
10205
+ }).conflicts("files", "branch"), async (argv) => {
10206
+ if (!argv.projectId) {
10207
+ reportError("deploy: missing required argument --project-id");
10208
+ process.exitCode = 1;
10209
+ return;
10210
+ }
10211
+ const tokens = tokenContext(argv.token);
10212
+ if (!tokens) return;
10213
+ const loaded = await loadConfig(argv.cwd);
10214
+ applyResolvedConfig(argv, loaded.resolved);
10215
+ const context = {
10216
+ repoRoot: argv.cwd,
10217
+ dryRun: argv.dryRun,
10218
+ ...tokens,
10219
+ org: resolveOrg(argv, loaded.resolved)
10220
+ };
10221
+ if (argv.files) {
10222
+ if ((await runDeployFromFiles({
10223
+ loaded,
10224
+ context,
10225
+ projectId: argv.projectId,
10226
+ dir: argv.files,
10227
+ ...argv.target ? { target: argv.target } : {}
10228
+ })).status === "fail") process.exitCode = 1;
10229
+ return;
10230
+ }
10231
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
10232
+ if ((await runDeploy({
10233
+ loaded,
10234
+ context,
10235
+ projectId: argv.projectId,
10236
+ branch,
10237
+ ...argv.target ? { target: argv.target } : {}
8903
10238
  })).status === "fail") process.exitCode = 1;
8904
- }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
8905
- await runAuthList();
8906
- }).command("$0", false, () => {}, async (argv) => {
8907
- await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "auth"), argv, "auth — choose a subcommand:", "Run `holocron auth --help` to see available auth subcommands.");
8908
- }), () => {}).command("$0", false, () => {}, async (argv) => {
8909
- await launchMenu(COMMAND_REGISTRY.filter((e) => !e.group), argv);
8910
10239
  }).strict().help().epilogue(`Execution contexts:
8911
10240
  global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
8912
10241
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
8913
10242
  captureException(err);
8914
- getLogger().error(err.message);
10243
+ reportError(err.message);
8915
10244
  errorReported = true;
8916
10245
  process.exitCode = 1;
8917
10246
  return;
8918
10247
  }
8919
10248
  if (err) throw err;
8920
- getLogger().error(msg);
10249
+ reportError(msg);
8921
10250
  errorReported = true;
8922
10251
  process.exitCode = 1;
8923
10252
  }).parse();
8924
10253
  } catch (err) {
8925
10254
  captureException(err);
8926
- if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
10255
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
8927
10256
  if (!process.exitCode) process.exitCode = 1;
8928
10257
  }
8929
10258
  finishCommand(!process.exitCode);