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

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
@@ -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
@@ -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,
@@ -3598,12 +4019,30 @@ const defaultLogin = async (cwd) => {
3598
4019
  };
3599
4020
  //#endregion
3600
4021
  //#region src/commands/secret-set.ts
4022
+ /**
4023
+ * `holocron secrets set` — one-shot single-secret push.
4024
+ *
4025
+ * Complements `holocron secrets sync` (bulk vault → destinations) for
4026
+ * ad-hoc scenarios:
4027
+ * - Bootstrap secrets (e.g., NPM_TOKEN on theholocron/holocron itself
4028
+ * before the rest of the setup exists)
4029
+ * - One-off rotations that don't warrant a vault sync
4030
+ * - Setting secrets the vault doesn't track
4031
+ *
4032
+ * Value source resolution (in priority order):
4033
+ * 1. Positional `value` argument
4034
+ * 2. `--from-stdin` reads from stdin
4035
+ * 3. `--from-env <NAME>` reads from the named env var
4036
+ * 4. Implicit: env var matching the secret name (e.g., `NPM_TOKEN`)
4037
+ *
4038
+ * Errors clearly when no value can be sourced.
4039
+ */
3601
4040
  async function runSecretSet(input) {
3602
4041
  const print = input.print ?? ((line) => console.log(line));
3603
4042
  const logger = input.logger ?? getLogger();
3604
4043
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3605
4044
  await loader.load();
3606
- assertPluginsResolvable(loader, "secret set");
4045
+ assertPluginsResolvable(loader, "secrets set");
3607
4046
  const dryRun = input.context.dryRun ?? false;
3608
4047
  const scope = input.scope ?? { kind: "repo" };
3609
4048
  if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
@@ -3636,7 +4075,7 @@ async function runSecretSet(input) {
3636
4075
  scope
3637
4076
  };
3638
4077
  } catch (err) {
3639
- const message = err instanceof Error ? err.message : String(err);
4078
+ const message = errorMessage(err);
3640
4079
  print(` ✗ ${message}`);
3641
4080
  logger.warn({
3642
4081
  key: input.name,
@@ -3672,8 +4111,40 @@ function describeScope(scope) {
3672
4111
  }
3673
4112
  //#endregion
3674
4113
  //#region src/commands/secrets-sync.ts
3675
- async function runSecretsSync(input) {
3676
- const print = input.print ?? ((line) => console.log(line));
4114
+ /**
4115
+ * `holocron secrets sync` — vault → destinations.
4116
+ *
4117
+ * Reads a named environment from the configured `vault` (1P
4118
+ * Environment / similar) as KEY=VALUE pairs, then fans those values
4119
+ * out to the configured destinations:
4120
+ *
4121
+ * - `secrets` capability (e.g., GH Actions secrets) — repo-scope
4122
+ * - `deployment` capability (e.g., Vercel env vars) — per-target
4123
+ * - local `.env` file (optional, planned for v5.1 — currently skipped
4124
+ * with a soft "not yet" note)
4125
+ *
4126
+ * Per-secret routing rules:
4127
+ *
4128
+ * - All keys go to `deployment` for each target the caller asks for
4129
+ * (defaults: production + preview)
4130
+ * - `secrets` (GH Actions) defaults to every key, repo-scoped — but a
4131
+ * caller can pass `githubSecretKeys` (an allowlist, from repeatable
4132
+ * `--github-secret KEY` CLI values) to restrict `secrets` to only
4133
+ * those keys, and `githubSecretScope` (same `{ kind }` shape `secret
4134
+ * set`'s own `--scope repo|env=NAME|org=NAME` parses to) to apply one
4135
+ * scope to all of them. A key not in the allowlist is skipped for
4136
+ * `secrets` entirely — still synced to `deployment` if configured.
4137
+ * Needed for a vault holding a mix of deploy-only secrets and one
4138
+ * CI-consumed, org-wide secret (holocron#781's Sentinel case: 4
4139
+ * Vercel-only keys + one org-scoped Axiom ingest token
4140
+ * `theholocron/.github`'s dispatched-check workflow also reads).
4141
+ *
4142
+ * The vault MUST implement `readEnvironment(envId)` (optional method).
4143
+ * If it doesn't, the command errors with a hint to populate the keys
4144
+ * individually via `holocron secrets set` (also planned for v5.1).
4145
+ */
4146
+ async function runSecretsSync(input) {
4147
+ const print = input.print ?? ((line) => console.log(line));
3677
4148
  const logger = input.logger ?? getLogger();
3678
4149
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3679
4150
  await withSpinner("Loading plugins…", () => loader.load());
@@ -3696,10 +4167,13 @@ async function runSecretsSync(input) {
3696
4167
  const rows = [];
3697
4168
  if (loader.has("secrets")) {
3698
4169
  const secrets = loader.get("secrets");
3699
- print(style.step("secrets (repo scope)"));
4170
+ const scope = input.githubSecretScope ?? { kind: "repo" };
4171
+ const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
4172
+ print(style.step("secrets"));
3700
4173
  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]);
4174
+ if (allowedKeys && !allowedKeys.has(key)) continue;
4175
+ rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
4176
+ await secrets.setSecret(scope, key, envVars[key]);
3703
4177
  }));
3704
4178
  print(formatRow(rows[rows.length - 1]));
3705
4179
  }
@@ -3779,7 +4253,7 @@ async function runRow(destination, scope, key, dryRun, body) {
3779
4253
  scope,
3780
4254
  key,
3781
4255
  status: "fail",
3782
- message: err instanceof Error ? err.message : String(err)
4256
+ message: errorMessage(err)
3783
4257
  };
3784
4258
  }
3785
4259
  }
@@ -3791,6 +4265,11 @@ function formatRow(row) {
3791
4265
  if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3792
4266
  return ` ${style.dim(`· ${label}`)}`;
3793
4267
  }
4268
+ function scopeLabel(scope) {
4269
+ if (scope.kind === "repo") return "scope=repo";
4270
+ if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
4271
+ return `scope=environment,env=${scope.name}`;
4272
+ }
3794
4273
  function vaultProviderName(loader) {
3795
4274
  if (!loader.has("vault")) return "<missing>";
3796
4275
  return loader.get("vault").providerName;
@@ -3867,7 +4346,7 @@ function deriveCapabilities(providers) {
3867
4346
  return Object.keys(providers ?? {}).sort();
3868
4347
  }
3869
4348
  /**
3870
- * `source` + `ci` are the minimal baseline every repo `holocron setup`
4349
+ * `source` + `ci` are the minimal baseline every repo `holocron repo setup`
3871
4350
  * touches wires — every `holocron.config.ts` in this org already satisfies
3872
4351
  * this, so `non-compliant` here means drift (a provider manually removed
3873
4352
  * after setup), not an unmet aspirational policy. Deliberately not a richer
@@ -3995,7 +4474,7 @@ var config_default = "# Configuration for sentiment-bot - https://github.com/beh
3995
4474
  //#endregion
3996
4475
  //#region src/utils/create-header.ts
3997
4476
  function createHeader(options) {
3998
- const { source, tool = "holocron setup", forPrimary = false } = options;
4477
+ const { source, tool = "holocron repo setup", forPrimary = false } = options;
3999
4478
  const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
4000
4479
  return {
4001
4480
  workflowHeader(format = "yaml") {
@@ -4019,7 +4498,7 @@ function createHeader(options) {
4019
4498
  },
4020
4499
  scaffoldHeader() {
4021
4500
  return [
4022
- `# Scaffolded by holocron setup — edit this file freely.`,
4501
+ `# Scaffolded by holocron repo setup — edit this file freely.`,
4023
4502
  `# Source: theholocron/holocron · ${source}`,
4024
4503
  ``
4025
4504
  ].join("\n");
@@ -4033,6 +4512,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
4033
4512
  //#region src/templates/configs/alexjs/alexrc.json
4034
4513
  var alexrc_default = { allow: [
4035
4514
  "dead",
4515
+ "execute",
4516
+ "execution",
4517
+ "executes",
4036
4518
  "failure",
4037
4519
  "failures",
4038
4520
  "hook",
@@ -4051,12 +4533,21 @@ function createIgnoreConfig$1() {
4051
4533
  }
4052
4534
  //#endregion
4053
4535
  //#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";
4536
+ var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
4055
4537
  //#endregion
4056
4538
  //#region src/templates/configs/commit-msg/create-config.ts
4057
4539
  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}`;
4540
+ /**
4541
+ * `.husky/commit-msg` — lints the commit message via `holocron run lint
4542
+ * commit-msg` (holocron#789), the real programmatic replacement for
4543
+ * shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
4544
+ * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4545
+ *
4546
+ * @param holocronScript - the command that invokes the CLI. Defaults to
4547
+ * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4548
+ */
4549
+ function createConfig$7(holocronScript = "pnpm exec holocron") {
4550
+ return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4060
4551
  }
4061
4552
  //#endregion
4062
4553
  //#region src/templates/configs/devmoji/create-config.ts
@@ -4072,7 +4563,7 @@ function createConfig$6() {
4072
4563
  }
4073
4564
  //#endregion
4074
4565
  //#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";
4566
+ 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
4567
  //#endregion
4077
4568
  //#region src/templates/configs/editorconfig/create-config.ts
4078
4569
  const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
@@ -4129,7 +4620,7 @@ var pre_commit_default = "# Run GitLeaks against staged changes only — a full
4129
4620
  const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
4130
4621
  /**
4131
4622
  * `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
4132
- * lint-staged before every commit. Installed by `holocron setup` when hooks
4623
+ * lint-staged before every commit. Installed by `holocron repo setup` when hooks
4133
4624
  * are enabled (default for `protection: "strict"`). `git commit --no-verify`
4134
4625
  * bypasses it.
4135
4626
  *
@@ -4144,13 +4635,13 @@ function createConfig$3() {
4144
4635
  }
4145
4636
  //#endregion
4146
4637
  //#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";
4638
+ 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
4639
  //#endregion
4149
4640
  //#region src/templates/configs/pre-push/create-config.ts
4150
4641
  const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
4151
4642
  /**
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
4643
+ * `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
4644
+ * before every push. Installed by `holocron repo setup` when hooks are enabled
4154
4645
  * (default for `protection: "strict"`). `git push --no-verify` bypasses it.
4155
4646
  *
4156
4647
  * @param holocronScript - the command that invokes the CLI. Defaults to
@@ -4197,7 +4688,7 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
4197
4688
  /**
4198
4689
  * Canonical AI engineering workflow role prompts.
4199
4690
  *
4200
- * Written to `.agents/prompts/<role>.md` by `holocron setup` when `agent` is
4691
+ * Written to `.agents/prompts/<role>.md` by `holocron repo setup` when `agent` is
4201
4692
  * configured. Paths are gitignored and regenerated on every setup run so the
4202
4693
  * content always reflects the current CLI version.
4203
4694
  *
@@ -4695,7 +5186,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
4695
5186
  capability: "source",
4696
5187
  step,
4697
5188
  status: "fail",
4698
- message: err instanceof Error ? err.message : String(err)
5189
+ message: errorMessage(err)
4699
5190
  };
4700
5191
  }
4701
5192
  try {
@@ -4718,7 +5209,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
4718
5209
  capability: "source",
4719
5210
  step,
4720
5211
  status: "fail",
4721
- message: err instanceof Error ? err.message : String(err)
5212
+ message: errorMessage(err)
4722
5213
  };
4723
5214
  }
4724
5215
  }
@@ -4811,7 +5302,7 @@ async function execStep(capability, step, dryRun, body, opts = {}) {
4811
5302
  capability,
4812
5303
  step,
4813
5304
  status: "fail",
4814
- message: err instanceof Error ? err.message : String(err)
5305
+ message: errorMessage(err)
4815
5306
  };
4816
5307
  }
4817
5308
  }
@@ -4984,7 +5475,7 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
4984
5475
  //#endregion
4985
5476
  //#region src/commands/setup/run-setup.ts
4986
5477
  /**
4987
- * `holocron setup` — orchestrates per-capability setup actions across
5478
+ * `holocron repo setup` — orchestrates per-capability setup actions across
4988
5479
  * every plugin loaded from `holocron.config.json`.
4989
5480
  *
4990
5481
  * Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
@@ -5004,18 +5495,18 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
5004
5495
  */
5005
5496
  const { workflowHeader: workflowHeader$1 } = createHeader({
5006
5497
  source: "packages/cli/src/commands/setup/run-setup.ts",
5007
- tool: "holocron setup"
5498
+ tool: "holocron repo setup"
5008
5499
  });
5009
5500
  const { workflowHeader: thinCallerHeader } = createHeader({
5010
5501
  source: "packages/astromech/src/thin-callers.ts",
5011
- tool: "holocron setup"
5502
+ tool: "holocron repo setup"
5012
5503
  });
5013
5504
  async function runSetup(input) {
5014
5505
  const print = input.print ?? ((line) => console.log(line));
5015
5506
  const logger = getLogger();
5016
5507
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
5017
5508
  await withSpinner("Loading plugins…", () => loader.load());
5018
- assertPluginsResolvable(loader, "setup");
5509
+ assertPluginsResolvable(loader, "repo setup");
5019
5510
  const config = input.loaded.resolved;
5020
5511
  const dryRun = input.context.dryRun ?? false;
5021
5512
  const steps = [];
@@ -5052,7 +5543,10 @@ async function runSetup(input) {
5052
5543
  print(formatStep(steps[steps.length - 1]));
5053
5544
  if (effectivePreset && effectivePreset !== "none") {
5054
5545
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
5055
- await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
5546
+ await source.updateRepoSettings({
5547
+ ...BALANCED_REPO_SETTINGS,
5548
+ ...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
5549
+ });
5056
5550
  }));
5057
5551
  print(formatStep(steps[steps.length - 1]));
5058
5552
  const tasksConfig = { tasks: config.tasks };
@@ -5159,13 +5653,13 @@ async function runSetup(input) {
5159
5653
  }));
5160
5654
  print(formatStep(steps[steps.length - 1]));
5161
5655
  steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5162
- await source.writeRepoFile(".husky/commit-msg", createConfig$7());
5656
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5163
5657
  }));
5164
5658
  print(formatStep(steps[steps.length - 1]));
5165
5659
  if (hooksEnabled) {
5166
5660
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5167
5661
  await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
5168
- return "runs `holocron ci` before push — bypass with `git push --no-verify`";
5662
+ return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
5169
5663
  }));
5170
5664
  print(formatStep(steps[steps.length - 1]));
5171
5665
  steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
@@ -5233,13 +5727,20 @@ async function runSetup(input) {
5233
5727
  await source.writeRepoFile("turbo.json", content);
5234
5728
  }));
5235
5729
  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);
5730
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5731
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5237
5732
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5238
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5733
+ const rootScripts = rootPkg?.scripts ?? {};
5239
5734
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5240
5735
  if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5241
5736
  await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5242
5737
  }));
5738
+ if (rootPkgRaw) {
5739
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5740
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5741
+ await source.writeRepoFile("package.json", depFix.content);
5742
+ }));
5743
+ }
5243
5744
  }
5244
5745
  print(formatStep(steps[steps.length - 1]));
5245
5746
  }
@@ -5291,34 +5792,36 @@ async function runSetup(input) {
5291
5792
  print(formatStep(steps[steps.length - 1]));
5292
5793
  }
5293
5794
  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
- }));
5795
+ if (teams.length > 0) {
5796
+ if (source.syncTeams) {
5797
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5798
+ print(formatStep(steps[steps.length - 1]));
5799
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
5800
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5801
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
5802
+ slug: t,
5803
+ permission: "push"
5804
+ } : t).filter((t) => [
5805
+ "push",
5806
+ "maintain",
5807
+ "admin"
5808
+ ].includes(t.permission));
5809
+ if (org && writeableTeams.length > 0) {
5810
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5811
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5812
+ await source.writeRepoFile(".github/CODEOWNERS", content);
5813
+ }));
5814
+ print(formatStep(steps[steps.length - 1]));
5815
+ }
5816
+ } else {
5817
+ steps.push({
5818
+ capability: "source",
5819
+ step: "sync teams",
5820
+ status: "skip",
5821
+ message: "provider does not implement syncTeams"
5822
+ });
5312
5823
  print(formatStep(steps[steps.length - 1]));
5313
5824
  }
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
5825
  }
5323
5826
  }
5324
5827
  if (loader.has("environments")) {
@@ -5374,11 +5877,9 @@ async function runSetup(input) {
5374
5877
  const wikiProxy = wiki.proxyConfig?.();
5375
5878
  if (wikiDns && loader.has("dns")) {
5376
5879
  const dns = loader.get("dns");
5377
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5880
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5378
5881
  await dns.upsertRecord(wikiDns.zone, {
5379
- type: "CNAME",
5380
- name: wikiDns.cname,
5381
- content: wikiDns.target,
5882
+ ...wikiDns.record,
5382
5883
  ttl: 1,
5383
5884
  ...wikiProxy ? { proxied: true } : {}
5384
5885
  });
@@ -5387,8 +5888,8 @@ async function runSetup(input) {
5387
5888
  }
5388
5889
  if (wikiProxy && wikiDns && loader.has("workers")) {
5389
5890
  const workers = loader.get("workers");
5390
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5391
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5891
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5892
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5392
5893
  }));
5393
5894
  print(formatStep(steps[steps.length - 1]));
5394
5895
  }
@@ -5401,11 +5902,31 @@ async function runSetup(input) {
5401
5902
  org: config.org,
5402
5903
  domain: config.domain
5403
5904
  }) : null;
5905
+ const ensureCustomDomain = async (projectId, hostname) => {
5906
+ if (!deploy.ensureCustomDomain) return;
5907
+ const customDomainDns = { value: null };
5908
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5909
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5910
+ }));
5911
+ print(formatStep(steps[steps.length - 1]));
5912
+ if (customDomainDns.value && loader.has("dns")) {
5913
+ const dns = loader.get("dns");
5914
+ const dnsRecord = customDomainDns.value;
5915
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5916
+ await dns.upsertRecord(dnsRecord.zone, {
5917
+ ...dnsRecord.record,
5918
+ ttl: 1
5919
+ });
5920
+ }));
5921
+ print(formatStep(steps[steps.length - 1]));
5922
+ }
5923
+ };
5404
5924
  if (!previewCfg) {
5405
5925
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5406
5926
  await deploy.ensureProject({ name: config.name });
5407
5927
  }));
5408
5928
  print(formatStep(steps[steps.length - 1]));
5929
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5409
5930
  }
5410
5931
  if (previewCfg) {
5411
5932
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5413,13 +5934,8 @@ async function runSetup(input) {
5413
5934
  }));
5414
5935
  print(formatStep(steps[steps.length - 1]));
5415
5936
  }
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")) {
5937
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5938
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5423
5939
  const dns = loader.get("dns");
5424
5940
  const wildcardDomain = `*.${previewCfg.domain}`;
5425
5941
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5493,7 +6009,7 @@ async function runSetup(input) {
5493
6009
  capability: "vault",
5494
6010
  step: "list",
5495
6011
  status: "fail",
5496
- message: err instanceof Error ? err.message : String(err)
6012
+ message: errorMessage(err)
5497
6013
  });
5498
6014
  }
5499
6015
  print(formatStep(steps[steps.length - 1]));
@@ -5532,6 +6048,17 @@ async function runSetup(input) {
5532
6048
  print(formatStep(steps[steps.length - 1]));
5533
6049
  }
5534
6050
  }
6051
+ if (loader.has("monitoring")) {
6052
+ const monitoring = loader.get("monitoring");
6053
+ print(style.step("monitoring"));
6054
+ for (const checkInput of monitoring.checks) {
6055
+ steps.push(await runStep("monitoring", `ensureCheck ${checkInput.name}`, dryRun, async () => {
6056
+ const result = await monitoring.ensureCheck(checkInput);
6057
+ return `${checkInput.name} ${result.alreadyExists ? "exists" : "created"}`;
6058
+ }));
6059
+ print(formatStep(steps[steps.length - 1]));
6060
+ }
6061
+ }
5535
6062
  if (loader.has("tooling")) {
5536
6063
  const tools = loader.get("tooling");
5537
6064
  print(style.step("tooling"));
@@ -5618,7 +6145,7 @@ async function runSetup(input) {
5618
6145
  print(style.hint(" · Metadata — read (repository permission, auto-included)"));
5619
6146
  print("");
5620
6147
  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>"));
6148
+ print(style.hint(" Then re-run: holocron repo setup --token <your-admin-pat>"));
5622
6149
  print(style.hint(" Store org token: HOLOCRON_ORG_TOKEN env var or keyring key github.org"));
5623
6150
  }
5624
6151
  return {
@@ -5656,7 +6183,7 @@ async function ensurePrepareScript(repoRoot) {
5656
6183
  /**
5657
6184
  * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
5658
6185
  *
5659
- * Unlike `holocron setup`, this command is purely local (no GitHub token
6186
+ * Unlike `holocron repo setup`, this command is purely local (no GitHub token
5660
6187
  * required). It reads `agent` and `skills` from the config and installs
5661
6188
  * the listed skills into `.agents/skills/<name>/` with a symlink at the
5662
6189
  * agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
@@ -5709,7 +6236,7 @@ async function runSkillsInstall(input) {
5709
6236
  status: "ok"
5710
6237
  }, "skills install: done");
5711
6238
  } catch (err) {
5712
- const message = err instanceof Error ? err.message : String(err);
6239
+ const message = errorMessage(err);
5713
6240
  print(` ✗ ${message}`);
5714
6241
  logger.warn({
5715
6242
  agent: config.agent,
@@ -5867,11 +6394,13 @@ async function runSyncReadme(input) {
5867
6394
  }
5868
6395
  const description = loaded.resolved.description ?? "";
5869
6396
  const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
6397
+ const scripts = pkg.scripts ?? {};
6398
+ const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
5870
6399
  const sections = generateReadme({
5871
6400
  description,
5872
6401
  homepage,
5873
- scripts: pkg.scripts ?? {},
5874
- packages: pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0
6402
+ scripts,
6403
+ packages
5875
6404
  });
5876
6405
  const markerSections = {
5877
6406
  ...sections.packages !== void 0 && { packages: sections.packages },
@@ -6113,7 +6642,7 @@ async function runSyncWiki(input) {
6113
6642
  capability: "local",
6114
6643
  step: "sync wiki",
6115
6644
  status: "fail",
6116
- message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6645
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6117
6646
  };
6118
6647
  }
6119
6648
  }
@@ -6121,7 +6650,7 @@ async function runSyncWiki(input) {
6121
6650
  //#region src/commands/sync.ts
6122
6651
  const { workflowHeader } = createHeader({
6123
6652
  source: "packages/astromech/src/thin-callers.ts",
6124
- tool: "holocron sync"
6653
+ tool: "holocron repo sync"
6125
6654
  });
6126
6655
  const SYNC_STEPS = [
6127
6656
  "labels",
@@ -6175,55 +6704,59 @@ async function runSync(input) {
6175
6704
  for (const stepName of SYNC_STEPS) {
6176
6705
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
6177
6706
  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]));
6707
+ if (stepName === "labels") {
6708
+ if (source.syncLabels) {
6709
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6710
+ print(formatSyncStep(steps[steps.length - 1]));
6711
+ } else {
6712
+ steps.push({
6713
+ capability: "source",
6714
+ step: "sync labels",
6715
+ status: "skip",
6716
+ message: "provider does not implement syncLabels"
6717
+ });
6718
+ print(formatSyncStep(steps[steps.length - 1]));
6719
+ }
6189
6720
  }
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
6721
+ if (stepName === "properties") {
6722
+ if (source.syncProperties) {
6723
+ const repo = config.repo;
6724
+ const properties = {};
6725
+ const effectivePreset = repo?.protection;
6726
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6727
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6728
+ properties["monorepo"] = String(isMonorepo);
6729
+ const manual = repo?.properties ?? {};
6730
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6731
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6732
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6733
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6734
+ {
6735
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6736
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6737
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6738
+ properties["holocron_profile"] = deriveProfile({
6739
+ rootPackageJson,
6740
+ repoName: basename(input.context.repoRoot),
6741
+ isMonorepo,
6742
+ workspacePackageJsons
6743
+ });
6744
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6745
+ const capabilities = deriveCapabilities(config.providers);
6746
+ properties["holocron_capabilities"] = capabilities;
6747
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6748
+ }
6749
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6750
+ print(formatSyncStep(steps[steps.length - 1]));
6751
+ } else {
6752
+ steps.push({
6753
+ capability: "source",
6754
+ step: "sync properties",
6755
+ status: "skip",
6756
+ message: "provider does not implement syncProperties"
6211
6757
  });
6212
- properties["holocron_stack"] = deriveStack(rootPackageJson);
6213
- const capabilities = deriveCapabilities(config.providers);
6214
- properties["holocron_capabilities"] = capabilities;
6215
- properties["holocron_compliance"] = deriveCompliance(capabilities);
6758
+ print(formatSyncStep(steps[steps.length - 1]));
6216
6759
  }
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
6760
  }
6228
6761
  if (stepName === "teams") {
6229
6762
  const teams = config.repo?.teams ?? [];
@@ -6435,29 +6968,34 @@ async function runSync(input) {
6435
6968
  }
6436
6969
  }
6437
6970
  }
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]));
6971
+ if (stepName === "scripts") {
6972
+ if (config.syncScripts === false) {
6973
+ steps.push({
6974
+ capability: "local",
6975
+ step: "sync scripts",
6976
+ status: "skip",
6977
+ message: "syncScripts: false"
6978
+ });
6979
+ print(formatSyncStep(steps[steps.length - 1]));
6980
+ } else {
6981
+ const tasksConfig = { tasks: config.tasks };
6982
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6983
+ const astromechScripts = createAstromech({
6984
+ cwd: input.context.repoRoot,
6985
+ config: tasksConfig,
6986
+ logger
6987
+ });
6988
+ const desired = astromechScripts.packageScripts();
6989
+ const turboContent = astromechScripts.turboConfig();
6990
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
6991
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6992
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
6993
+ if (changed === null) return "no package.json";
6994
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6995
+ return `${changed.join(", ")} set`;
6996
+ }));
6997
+ print(formatSyncStep(steps[steps.length - 1]));
6998
+ }
6461
6999
  }
6462
7000
  if (stepName === "turbo") {
6463
7001
  const astromechTurbo = createAstromech({
@@ -6479,9 +7017,10 @@ async function runSync(input) {
6479
7017
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6480
7018
  }));
6481
7019
  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);
7020
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
7021
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6483
7022
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6484
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
7023
+ const rootScripts = rootPkg?.scripts ?? {};
6485
7024
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6486
7025
  if (workspaceFix.changed) {
6487
7026
  steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
@@ -6489,6 +7028,15 @@ async function runSync(input) {
6489
7028
  }));
6490
7029
  print(formatSyncStep(steps[steps.length - 1]));
6491
7030
  }
7031
+ if (rootPkgRaw) {
7032
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
7033
+ if (depFix.changed) {
7034
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
7035
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
7036
+ }));
7037
+ print(formatSyncStep(steps[steps.length - 1]));
7038
+ }
7039
+ }
6492
7040
  }
6493
7041
  }
6494
7042
  if (stepName === "wiki") {
@@ -6546,7 +7094,7 @@ async function runSyncStep(capability, step, dryRun, body) {
6546
7094
  capability,
6547
7095
  step,
6548
7096
  status: "fail",
6549
- message: err instanceof Error ? err.message : String(err)
7097
+ message: errorMessage(err)
6550
7098
  };
6551
7099
  }
6552
7100
  }
@@ -6572,8 +7120,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6572
7120
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6573
7121
  * listed keys, never touches the rest. Returns the sorted list of keys that
6574
7122
  * drifted (empty when all current), or `null` when there is no `package.json`.
7123
+ *
7124
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
7125
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
7126
+ * only, an *existing* script that isn't the generated `holocron run <task>
7127
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
7128
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
7129
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
7130
+ * real command and, once `turbo.json` exists and root is a real workspace
7131
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
7132
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
7133
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
7134
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
7135
+ * legitimate direct-command alternative and stays fully generator-owned.
6575
7136
  */
6576
- async function mergePackageJsonScripts(repoRoot, desired) {
7137
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6577
7138
  const pkgPath = join(repoRoot, "package.json");
6578
7139
  let content;
6579
7140
  try {
@@ -6584,15 +7145,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6584
7145
  const pkg = JSON.parse(content);
6585
7146
  const scripts = pkg.scripts ?? {};
6586
7147
  const changed = [];
6587
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6588
- scripts[name] = command;
6589
- changed.push(name);
7148
+ for (const [name, command] of Object.entries(desired)) {
7149
+ const existing = scripts[name];
7150
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
7151
+ if (existing !== command) {
7152
+ scripts[name] = command;
7153
+ changed.push(name);
7154
+ }
6590
7155
  }
6591
7156
  if (changed.length === 0) return [];
6592
7157
  pkg.scripts = scripts;
6593
7158
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6594
7159
  return changed.sort();
6595
7160
  }
7161
+ /**
7162
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
7163
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
7164
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
7165
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
7166
+ * rather than the whole string means an old wrapper survives a
7167
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
7168
+ * to update to the new one), not mistaken for a real command.
7169
+ */
7170
+ function isGeneratedRunWrapper(command, taskName) {
7171
+ return command.endsWith(` run ${taskName} --`);
7172
+ }
6596
7173
  const README_DESC_START = "<!-- holocron:description -->";
6597
7174
  const README_DESC_END = "<!-- /holocron:description -->";
6598
7175
  async function updateReadmeDescription(repoRoot, description) {
@@ -6809,7 +7386,7 @@ const DEFAULT_REPO = "theholocron/.github";
6809
7386
  * The file set `sync-github` pushes to `theholocron/.github` — the reusable
6810
7387
  * `workflow_call` implementations, composite actions and starter-workflow
6811
7388
  * metadata, generated by `@theholocron/astromech`. Secondary repos get nothing:
6812
- * their thin callers are managed by `holocron sync --steps workflows`, which
7389
+ * their thin callers are managed by `holocron repo sync --steps workflows`, which
6813
7390
  * executes the resolved config (regex parsing here can't resolve preset spreads
6814
7391
  * like `...tasks` from `nodeDocs()` — see issue #464).
6815
7392
  */
@@ -6835,7 +7412,7 @@ async function runSyncGithub(input) {
6835
7412
  token,
6836
7413
  fetch: input.fetch
6837
7414
  });
6838
- print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
7415
+ print(`holocron sync github${dryRun ? " (dry-run)" : ""}`);
6839
7416
  print(` repo: ${repo}`);
6840
7417
  if (branch) print(` branch: ${branch}`);
6841
7418
  print("");
@@ -6964,7 +7541,7 @@ async function runSyncGithub(input) {
6964
7541
  sha: blob.sha
6965
7542
  });
6966
7543
  } catch (err) {
6967
- const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
7544
+ const msg = `failed to create blob for ${file.path}: ${errorMessage(err)}`;
6968
7545
  print(` ✗ ${msg}`);
6969
7546
  return done({
6970
7547
  status: "fail",
@@ -6978,7 +7555,7 @@ async function runSyncGithub(input) {
6978
7555
  try {
6979
7556
  newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
6980
7557
  } catch (err) {
6981
- const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
7558
+ const msg = `failed to create tree: ${errorMessage(err)}`;
6982
7559
  print(` ✗ ${msg}`);
6983
7560
  return done({
6984
7561
  status: "fail",
@@ -6992,7 +7569,7 @@ async function runSyncGithub(input) {
6992
7569
  try {
6993
7570
  newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
6994
7571
  } catch (err) {
6995
- const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
7572
+ const msg = `failed to create commit: ${errorMessage(err)}`;
6996
7573
  print(` ✗ ${msg}`);
6997
7574
  return done({
6998
7575
  status: "fail",
@@ -7011,7 +7588,7 @@ async function runSyncGithub(input) {
7011
7588
  }
7012
7589
  else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
7013
7590
  } catch (err) {
7014
- const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
7591
+ const msg = `failed to update ref: ${errorMessage(err)}`;
7015
7592
  print(` ✗ ${msg}`);
7016
7593
  return done({
7017
7594
  status: "fail",
@@ -7027,12 +7604,12 @@ async function runSyncGithub(input) {
7027
7604
  title: message.split("\n")[0],
7028
7605
  head: branch,
7029
7606
  base: "main",
7030
- body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
7607
+ body: "Auto-generated by `holocron sync github`. Review and merge to apply template updates."
7031
7608
  })).html_url;
7032
7609
  print(` → PR opened: ${prUrl}`);
7033
7610
  } catch (err) {
7034
7611
  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)}`);
7612
+ else print(` ⚠ PR creation failed: ${errorMessage(err)}`);
7036
7613
  }
7037
7614
  return done({
7038
7615
  status: "ok",
@@ -7045,7 +7622,7 @@ async function runSyncGithub(input) {
7045
7622
  //#endregion
7046
7623
  //#region src/commands/upgrade-deps.ts
7047
7624
  /**
7048
- * `holocron upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7625
+ * `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7049
7626
  * its `holocron.config.ts` up to the current major.
7050
7627
  *
7051
7628
  * Two concerns:
@@ -7060,7 +7637,7 @@ async function runSyncGithub(input) {
7060
7637
  * This applies the mechanical transforms and flags anything that needs a
7061
7638
  * human eye.
7062
7639
  *
7063
- * After running, `pnpm install && holocron ci`.
7640
+ * After running, `pnpm install && holocron run ci`.
7064
7641
  */
7065
7642
  const FETCH_TIMEOUT_MS$1 = 4e3;
7066
7643
  /** Resolve a package's `latest` dist-tag from the npm registry. */
@@ -7127,7 +7704,7 @@ function isLegacyConfig(content) {
7127
7704
  }
7128
7705
  /**
7129
7706
  * 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`.
7707
+ * effort — the result should always be reviewed and run through `holocron run ci`.
7131
7708
  */
7132
7709
  function migrateConfig(content) {
7133
7710
  const transforms = [];
@@ -7253,7 +7830,7 @@ async function runUpgradeDeps(input = {}) {
7253
7830
  print("");
7254
7831
  if (bumps.length === 0 && !configMigrated) print("Nothing to do — already up to date.");
7255
7832
  else {
7256
- print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron ci\``);
7833
+ print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron run ci\``);
7257
7834
  if (configMigrated) print(" review holocron.config.ts before committing.");
7258
7835
  }
7259
7836
  const status = dryRun ? "dry-run" : "ok";
@@ -7459,6 +8036,63 @@ async function runUpgradeNode(input) {
7459
8036
  };
7460
8037
  }
7461
8038
  //#endregion
8039
+ //#region src/commit-lint/lint-message.ts
8040
+ /**
8041
+ * Lints one commit message string against an already-loaded commitlint
8042
+ * config. The genuinely environment-independent half of commit-message
8043
+ * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
8044
+ * caller (Sentinel isolates itself from any ambient config since it runs
8045
+ * standalone, away from a real repo checkout; `lintCommitMsgFile` below
8046
+ * just lets cosmiconfig discover the invoking repo's own
8047
+ * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
8048
+ * but linting one message against a loaded config is identical either way.
8049
+ *
8050
+ * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
8051
+ * itself uses internally — never a reimplementation of commitlint's rules.
8052
+ */
8053
+ /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
8054
+ function selectParserOpts(parserPreset) {
8055
+ return parserPreset?.parserOpts;
8056
+ }
8057
+ async function lintCommitMessage(message, loaded) {
8058
+ const opts = {
8059
+ /* istanbul ignore next -- see comment above */
8060
+ parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
8061
+ plugins: loaded.plugins,
8062
+ /* istanbul ignore next -- see comment above */
8063
+ ignores: loaded.ignores ?? [],
8064
+ defaultIgnores: loaded.defaultIgnores !== false
8065
+ };
8066
+ const outcome = await lint(message, loaded.rules, opts);
8067
+ if (outcome.valid) return [];
8068
+ return outcome.errors.map((error) => ({
8069
+ rule: error.name,
8070
+ message: error.message
8071
+ }));
8072
+ }
8073
+ //#endregion
8074
+ //#region src/commit-lint/lint-commit-msg-file.ts
8075
+ /**
8076
+ * `holocron run lint commit-msg <file>` — a real programmatic replacement for
8077
+ * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
8078
+ * (holocron#789). Matches that CLI's own contract exactly: `file` is a
8079
+ * path to the commit message being written (the arg git's `commit-msg`
8080
+ * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
8081
+ * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
8082
+ * discovers the invoking repo's real `commitlint.config.*` file, exactly
8083
+ * as `commitlint --edit` (no `--config` flag) already does today. Unlike
8084
+ * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
8085
+ * runs inside a real repo checkout with a real `node_modules`, not
8086
+ * standalone on a server.
8087
+ */
8088
+ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8089
+ const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
8090
+ return {
8091
+ valid: violations.length === 0,
8092
+ violations
8093
+ };
8094
+ }
8095
+ //#endregion
7462
8096
  //#region src/config/load-config.ts
7463
8097
  /**
7464
8098
  * `holocron.config.{ts,js,mjs,cjs,json}` loader.
@@ -7473,14 +8107,19 @@ async function runUpgradeNode(input) {
7473
8107
  */
7474
8108
  const execFileAsync = promisify(execFile);
7475
8109
  /**
7476
- * Read + parse + resolve `holocron.config.*` from the given directory.
8110
+ * Read + parse + resolve `holocron.config.*` starting from the given
8111
+ * directory, walking up through ancestors to the nearest `.git` boundary
8112
+ * when `cwd` itself has no match — every command is usable from any
8113
+ * subdirectory of a repo, not only its exact root (see
8114
+ * `@theholocron/datapad`'s own doc comment for the stop conditions).
7477
8115
  * Throws {@link ConfigFileError} when nothing is found or a file cannot
7478
- * be loaded, or `ConfigError` when the config is invalid.
8116
+ * be loaded, or `ConfigError` when the config fails schema checks.
7479
8117
  */
7480
8118
  async function loadConfig(cwd) {
7481
8119
  const found = await loadConfigFile({
7482
8120
  cwd,
7483
- name: "holocron"
8121
+ name: "holocron",
8122
+ walkUp: true
7484
8123
  });
7485
8124
  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
8125
  return {
@@ -7553,6 +8192,15 @@ function parseGitRemoteUrl(url) {
7553
8192
  * {@link promptForPositionals} for just that one command — no spawn needed,
7554
8193
  * the handler already has everything else it needs.
7555
8194
  *
8195
+ * For a command whose handler honours `--dry-run` ({@link
8196
+ * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8197
+ * dry run doesn't mutate anything, so there's nothing to ask permission
8198
+ * for — then, once that preview comes back clean and found something to
8199
+ * do, asks the one real decision: "proceed with the real run?". A preview
8200
+ * that finds nothing to do skips that question too. `--skip-dry-run` opts
8201
+ * a top-level invocation out of the preview entirely, straight to the
8202
+ * real run, for when you already know what you want.
8203
+ *
7556
8204
  * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
7557
8205
  * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
7558
8206
  * hanging on `@inquirer/prompts` (which otherwise throws its own
@@ -7566,40 +8214,153 @@ var NonInteractiveError = class extends Error {
7566
8214
  };
7567
8215
  const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
7568
8216
  /**
8217
+ * This repo's own resolved task manifest, filtered to what's actually
8218
+ * runnable locally — `Astromech.runnableTasks()`, reused rather than
8219
+ * reimplemented (deliberately *not* `packageScripts()`, whose
8220
+ * `syncScripts: false` short-circuit returns `{}` for repos — this one
8221
+ * included — that opt out of generated `package.json` scripts but still
8222
+ * have real runnable tasks). Empty (never throws) when there's no config to
8223
+ * resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
8224
+ * as "run ›" simply having no children.
8225
+ */
8226
+ async function listRunnableTasks(argv) {
8227
+ const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
8228
+ const config = await loadTasksConfig(cwd).catch(() => void 0);
8229
+ return createAstromech({
8230
+ cwd,
8231
+ config
8232
+ }).runnableTasks();
8233
+ }
8234
+ /**
8235
+ * Namespace display order for the `run` menu — deliberately its own thing,
8236
+ * not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
8237
+ * signal first" in an automated gate, a different goal from "how a person
8238
+ * would naturally work through a repo" (build it, clean up its style, check
8239
+ * it's secure, test it, validate it, document it). A namespace absent here
8240
+ * (a repo-specific one this list doesn't know about) sorts after every
8241
+ * named one, in whatever order it was first seen.
8242
+ */
8243
+ const RUN_MENU_NAMESPACE_ORDER = [
8244
+ "delivery",
8245
+ "sourceQuality",
8246
+ "security",
8247
+ "verification",
8248
+ "platform",
8249
+ "knowledge"
8250
+ ];
8251
+ /**
8252
+ * Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
8253
+ * groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
8254
+ * within each group is `taskNames`' own order (already declaration order,
8255
+ * from {@link listRunnableTasks}). A name with no `.` at all (defensive;
8256
+ * every real registry task has a namespace) is its own bucket, keyed by the
8257
+ * full name, sorted after every namespaced group.
8258
+ */
8259
+ function groupByNamespace(taskNames) {
8260
+ const groups = /* @__PURE__ */ new Map();
8261
+ for (const name of taskNames) {
8262
+ const dot = name.indexOf(".");
8263
+ const key = dot === -1 ? name : name.slice(0, dot);
8264
+ const list = groups.get(key);
8265
+ if (list) list.push(name);
8266
+ else groups.set(key, [name]);
8267
+ }
8268
+ const ordered = /* @__PURE__ */ new Map();
8269
+ const byMenuOrder = (a, b) => {
8270
+ const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
8271
+ const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
8272
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
8273
+ return 0;
8274
+ };
8275
+ for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8276
+ return ordered;
8277
+ }
8278
+ /**
8279
+ * Dynamically-built `run` sub-entries for every locally-runnable task
8280
+ * ({@link listRunnableTasks}), grouped by namespace and merged into
8281
+ * `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
8282
+ * itself, since the task set is per-repo, not a static surface this module
8283
+ * can hand-enumerate.
8284
+ *
8285
+ * Each namespace becomes a group with a leading "<namespace> (run it)" item
8286
+ * (`holocron run <namespace>` — every task in it, via
8287
+ * `Astromech.runGroup()`) alongside each of its individual tasks, the exact
8288
+ * same "a group that's also runnable" shape `menuItems` already renders for
8289
+ * `repo sync` / `deploy` — reached here for free by using a space, not the
8290
+ * task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
8291
+ * the real dotted invocation `buildChildArgv` actually spawns).
8292
+ */
8293
+ function buildRunMenuEntries(taskNames) {
8294
+ const entries = [];
8295
+ for (const [namespace, tasks] of groupByNamespace(taskNames)) {
8296
+ const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
8297
+ if (isRealNamespace) entries.push({
8298
+ name: `run ${namespace}`,
8299
+ description: `Run every ${namespace}.* task in sequence`,
8300
+ positionals: [],
8301
+ supportsDryRun: true,
8302
+ spawnArgs: ["run", namespace]
8303
+ });
8304
+ for (const task of tasks) {
8305
+ const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
8306
+ entries.push({
8307
+ name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
8308
+ description: `Run ${task}`,
8309
+ positionals: [],
8310
+ supportsDryRun: true,
8311
+ spawnArgs: ["run", task]
8312
+ });
8313
+ }
8314
+ }
8315
+ return entries;
8316
+ }
8317
+ /**
7569
8318
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
7570
8319
  * 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.
8320
+ * `run <task>` has no entry here at all — `cli.ts`'s `$0` handler merges in
8321
+ * {@link buildRunMenuEntries}'s dynamically-built ones instead, since the
8322
+ * task namespace is this repo's own resolved manifest, not a static set
8323
+ * this module can hand-enumerate the way every other entry's positionals
8324
+ * are. `job` and `passthrough` stay un-prompted either way; picking a task
8325
+ * and running it plain covers the common case, and typing
8326
+ * `holocron run <task> <job> -- <args>` directly is one step away for
8327
+ * everything else. `run lint commit-msg <file>` stays excluded entirely:
8328
+ * it's invoked exclusively from `.husky/commit-msg "$1"`, never typed by a
8329
+ * human.
7573
8330
  */
7574
8331
  const COMMAND_REGISTRY = [
7575
- {
7576
- name: "version",
7577
- description: "Print the CLI version",
7578
- positionals: []
7579
- },
7580
8332
  {
7581
8333
  name: "clone",
7582
- description: "Clone all repos in a GitHub org as siblings under a single directory",
7583
- positionals: []
8334
+ description: "Clone a single repo (org/repo) or every repo in an org (org)",
8335
+ positionals: [{
8336
+ key: "target",
8337
+ message: "org/repo to clone (or a bare org to clone every repo in it):",
8338
+ type: "input",
8339
+ validate: (v) => v.trim().length > 0 || "org/repo or org is required"
8340
+ }],
8341
+ supportsDryRun: true
7584
8342
  },
7585
8343
  {
7586
8344
  name: "doctor",
7587
8345
  description: "Load the config and run a smoke check against every provider",
7588
- positionals: []
8346
+ positionals: [],
8347
+ supportsDryRun: true
7589
8348
  },
7590
8349
  {
7591
- name: "setup",
8350
+ name: "repo setup",
7592
8351
  description: "Apply infra setup actions across every configured capability",
7593
- positionals: []
8352
+ positionals: [],
8353
+ supportsDryRun: true
7594
8354
  },
7595
8355
  {
7596
- name: "secret set",
8356
+ name: "secrets set",
7597
8357
  description: "Set a single secret via the configured `secrets` capability",
7598
8358
  positionals: [{
7599
8359
  key: "name",
7600
8360
  message: "Secret name:",
7601
8361
  type: "input"
7602
- }]
8362
+ }],
8363
+ supportsDryRun: true
7603
8364
  },
7604
8365
  {
7605
8366
  name: "secrets sync",
@@ -7608,7 +8369,8 @@ const COMMAND_REGISTRY = [
7608
8369
  key: "environmentId",
7609
8370
  message: "Vault environment id:",
7610
8371
  type: "input"
7611
- }]
8372
+ }],
8373
+ supportsDryRun: true
7612
8374
  },
7613
8375
  {
7614
8376
  name: "deploy",
@@ -7617,52 +8379,66 @@ const COMMAND_REGISTRY = [
7617
8379
  key: "branch",
7618
8380
  message: "Branch to deploy:",
7619
8381
  type: "input"
7620
- }]
8382
+ }],
8383
+ supportsDryRun: true
7621
8384
  },
7622
8385
  {
7623
- name: "cleanup-preview",
8386
+ name: "deploy cleanup-preview",
7624
8387
  description: "List and delete Cloudflare Pages preview deployments for a GitHub PR",
7625
8388
  positionals: [{
7626
8389
  key: "pr",
7627
8390
  message: "PR number:",
7628
8391
  type: "input",
7629
8392
  validate: numeric
7630
- }]
8393
+ }],
8394
+ supportsDryRun: true
7631
8395
  },
7632
8396
  {
7633
- name: "bump-versions",
8397
+ name: "deploy on-release",
8398
+ description: "Deploy every workspace package whose delivery.deploy task is `on: release`",
8399
+ positionals: [],
8400
+ supportsDryRun: true
8401
+ },
8402
+ {
8403
+ name: "package bump-versions",
7634
8404
  description: "Bump all non-private package versions in lockstep (semantic-release prepareCmd)",
7635
8405
  positionals: [{
7636
8406
  key: "newVersion",
7637
8407
  cliArg: "new-version",
7638
8408
  message: "New version:",
7639
8409
  type: "input"
7640
- }]
8410
+ }],
8411
+ supportsDryRun: true
7641
8412
  },
7642
8413
  {
7643
- name: "publish",
8414
+ name: "package publish",
7644
8415
  description: "Publish @theholocron/* packages to npm",
7645
- positionals: []
8416
+ positionals: [],
8417
+ supportsDryRun: true
7646
8418
  },
7647
8419
  {
7648
- name: "sync",
8420
+ name: "repo sync",
7649
8421
  description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
7650
- positionals: []
8422
+ positionals: [],
8423
+ supportsDryRun: true
7651
8424
  },
7652
8425
  {
7653
- name: "ci",
8426
+ name: "run ci",
7654
8427
  description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
7655
- positionals: []
8428
+ positionals: [],
8429
+ supportsDryRun: true
7656
8430
  },
7657
8431
  {
7658
- name: "sync-github",
8432
+ name: "sync github",
7659
8433
  description: "Sync workflow templates and composite actions to theholocron/.github",
7660
- positionals: []
8434
+ positionals: [],
8435
+ supportsDryRun: true
7661
8436
  },
7662
8437
  {
7663
- name: "sync-readme",
8438
+ name: "repo sync readme",
7664
8439
  description: "Sync the Installation + Usage block in README.md from package.json",
7665
- positionals: []
8440
+ positionals: [],
8441
+ supportsDryRun: true
7666
8442
  },
7667
8443
  {
7668
8444
  name: "config show",
@@ -7670,12 +8446,13 @@ const COMMAND_REGISTRY = [
7670
8446
  positionals: []
7671
8447
  },
7672
8448
  {
7673
- name: "new",
8449
+ name: "new repo",
7674
8450
  description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
7675
- positionals: []
8451
+ positionals: [],
8452
+ supportsDryRun: true
7676
8453
  },
7677
8454
  {
7678
- name: "plugin create",
8455
+ name: "new plugin",
7679
8456
  description: "Scaffold a new @theholocron/holocron-plugin-<slug> package",
7680
8457
  positionals: [{
7681
8458
  key: "slug",
@@ -7685,28 +8462,29 @@ const COMMAND_REGISTRY = [
7685
8462
  key: "vendor",
7686
8463
  message: "Vendor display name (PascalCase):",
7687
8464
  type: "input"
7688
- }]
8465
+ }],
8466
+ supportsDryRun: true
7689
8467
  },
7690
8468
  {
7691
8469
  name: "skills install",
7692
8470
  description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
7693
8471
  positionals: [],
7694
- group: "skills"
8472
+ supportsDryRun: true
7695
8473
  },
7696
8474
  {
7697
8475
  name: "skills remove",
7698
8476
  description: "Remove installed skills via npx skills remove",
7699
8477
  positionals: [],
7700
- group: "skills"
8478
+ supportsDryRun: true
7701
8479
  },
7702
8480
  {
7703
8481
  name: "skills update",
7704
8482
  description: "Update installed skills to their latest upstream versions via npx skills update",
7705
8483
  positionals: [],
7706
- group: "skills"
8484
+ supportsDryRun: true
7707
8485
  },
7708
8486
  {
7709
- name: "upgrade node",
8487
+ name: "repo upgrade node",
7710
8488
  description: "Scan the repo and update every Node.js version pin to a new major",
7711
8489
  positionals: [{
7712
8490
  key: "to",
@@ -7714,13 +8492,13 @@ const COMMAND_REGISTRY = [
7714
8492
  type: "input",
7715
8493
  validate: numeric
7716
8494
  }],
7717
- group: "upgrade"
8495
+ supportsDryRun: true
7718
8496
  },
7719
8497
  {
7720
- name: "upgrade deps",
8498
+ name: "repo upgrade deps",
7721
8499
  description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
7722
8500
  positionals: [],
7723
- group: "upgrade"
8501
+ supportsDryRun: true
7724
8502
  },
7725
8503
  {
7726
8504
  name: "auth set",
@@ -7729,8 +8507,7 @@ const COMMAND_REGISTRY = [
7729
8507
  key: "provider",
7730
8508
  message: "Provider name:",
7731
8509
  type: "input"
7732
- }],
7733
- group: "auth"
8510
+ }]
7734
8511
  },
7735
8512
  {
7736
8513
  name: "auth unset",
@@ -7740,8 +8517,7 @@ const COMMAND_REGISTRY = [
7740
8517
  message: "Provider:",
7741
8518
  type: "select",
7742
8519
  choices: listStoredProviders
7743
- }],
7744
- group: "auth"
8520
+ }]
7745
8521
  },
7746
8522
  {
7747
8523
  name: "auth check",
@@ -7751,14 +8527,12 @@ const COMMAND_REGISTRY = [
7751
8527
  message: "Provider:",
7752
8528
  type: "select",
7753
8529
  choices: listStoredProviders
7754
- }],
7755
- group: "auth"
8530
+ }]
7756
8531
  },
7757
8532
  {
7758
8533
  name: "auth list",
7759
8534
  description: "List every provider with a stored bootstrap token",
7760
- positionals: [],
7761
- group: "auth"
8535
+ positionals: []
7762
8536
  }
7763
8537
  ];
7764
8538
  /** Look up a {@link CommandEntry} by its full name — for Layer 3 call sites in `cli.ts`. */
@@ -7767,36 +8541,89 @@ function getEntry(name) {
7767
8541
  if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
7768
8542
  return entry;
7769
8543
  }
8544
+ const GROUP = "group:";
8545
+ /** The "← back" item's value; only offered below the first level. */
8546
+ const BACK = "back";
8547
+ /**
8548
+ * One level of the menu, derived from the registered command names: the next word
8549
+ * of every entry under `path` (`[]` is the top). A word is
8550
+ *
8551
+ * - a **command** when nothing is nested under it (`doctor`);
8552
+ * - a **group** when commands are nested under it (`repo` → `repo setup`, `repo sync`, …),
8553
+ * which can nest as deep as the names go (`repo upgrade` → `repo upgrade node`);
8554
+ *
8555
+ * Every row is a single word, never a "<command> <subcommand>" path: `config show` is
8556
+ * `config ›` then `show`, even when a group holds just one command.
8557
+ *
8558
+ * A path that is itself runnable (`repo sync`, `deploy`) gets a leading "<word> (run it)" item.
8559
+ * Registration order is kept.
8560
+ */
8561
+ function menuItems(entries, path = []) {
8562
+ const prefix = path.join(" ");
8563
+ const own = prefix ? entries.find((e) => e.name === prefix) : void 0;
8564
+ const under = prefix ? entries.filter((e) => e.name.startsWith(`${prefix} `)) : entries;
8565
+ const haystackOf = (list) => list.map((e) => `${e.name} ${e.description}`.toLowerCase()).join(" ");
8566
+ const items = [];
8567
+ if (own) items.push({
8568
+ name: `${path[path.length - 1]} (run it)`,
8569
+ value: own.name,
8570
+ description: own.description,
8571
+ haystack: haystackOf([own])
8572
+ });
8573
+ const words = [...new Set(under.map((e) => e.name.split(" ")[path.length]))];
8574
+ for (const word of words) {
8575
+ const full = [...path, word].join(" ");
8576
+ const family = under.filter((e) => e.name === full || e.name.startsWith(`${full} `));
8577
+ const self = family.find((e) => e.name === full);
8578
+ const nested = family.filter((e) => e.name !== full);
8579
+ if (nested.length === 0 && self) items.push({
8580
+ name: word,
8581
+ value: full,
8582
+ description: self.description,
8583
+ haystack: haystackOf([self])
8584
+ });
8585
+ else {
8586
+ const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
8587
+ items.push({
8588
+ name: `${word} ›`,
8589
+ value: `${GROUP}${full}`,
8590
+ description: children.join(", "),
8591
+ haystack: haystackOf(family)
8592
+ });
8593
+ }
8594
+ }
8595
+ return items;
8596
+ }
7770
8597
  /**
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.
8598
+ * Searchable autocomplete over one menu level's `items`. `select()` would work too
8599
+ * at these list sizes, but `search()` degrades gracefully as the surface grows and
8600
+ * costs nothing when it doesn't. Resolves to the picked item's `value`.
7775
8601
  */
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({
8602
+ async function pickMenuItem(items, message = "What would you like to do?") {
8603
+ return search({
7779
8604
  message,
7780
- source: (term) => searchChoices(entries, term)
8605
+ source: (term) => searchChoices(items, term)
7781
8606
  });
7782
- return byName.get(picked);
7783
8607
  }
7784
8608
  /**
7785
8609
  * `@inquirer/search`'s `source` callback, factored out as a plain function —
7786
8610
  * unit-testable directly instead of only through a mocked `search()` call.
7787
- * Empty/undefined `term` (nothing typed yet) returns every entry.
8611
+ * Empty/undefined `term` (nothing typed yet) returns every item.
7788
8612
  */
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
8613
+ function searchChoices(items, term) {
8614
+ const needle = term?.toLowerCase();
8615
+ const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
8616
+ const rank = (i) => {
8617
+ if (!needle) return 0;
8618
+ const name = i.name.toLowerCase();
8619
+ return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
8620
+ };
8621
+ return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
8622
+ name: i.name,
8623
+ value: i.value,
8624
+ description: i.description
7794
8625
  }));
7795
8626
  }
7796
- function matches(entry, term) {
7797
- const needle = term.toLowerCase();
7798
- return entry.name.toLowerCase().includes(needle) || entry.description.toLowerCase().includes(needle);
7799
- }
7800
8627
  /**
7801
8628
  * For each of `entry.positionals` not already present in `argv`, prompts for
7802
8629
  * it (`input()` or `select()`, per the positional's `type`) and returns the
@@ -7835,78 +8662,426 @@ async function promptForPositionals(entry, argv) {
7835
8662
  * menu-launched child should inherit from the parent invocation. Everything
7836
8663
  * else (command-specific options) was never captured by the top-level `$0`
7837
8664
  * handler in the first place, so there's nothing else to forward.
8665
+ *
8666
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8667
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
8668
+ * force it back off for the real-run spawn that follows, regardless of
8669
+ * what was on the original invocation.
7838
8670
  */
7839
- function forwardedFlags(argv) {
8671
+ function forwardedFlags(argv, dryRunOverride) {
7840
8672
  const out = [];
7841
8673
  const tokens = argv.token;
7842
8674
  for (const t of tokens ?? []) out.push("--token", t);
7843
8675
  if (typeof argv.org === "string") out.push("--org", argv.org);
7844
8676
  if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
7845
- if (argv.dryRun === true) out.push("--dry-run");
8677
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
7846
8678
  return out;
7847
8679
  }
7848
- /** `entry.name` split into tokens, resolved positionals appended, then forwarded flags. */
7849
- function buildChildArgv(entry, positionals, parentArgv) {
8680
+ /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8681
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
7850
8682
  return [
7851
- ...entry.name.split(" "),
8683
+ ...entry.spawnArgs ?? entry.name.split(" "),
7852
8684
  ...positionals,
7853
- ...forwardedFlags(parentArgv)
8685
+ ...forwardedFlags(parentArgv, dryRunOverride)
7854
8686
  ];
7855
8687
  }
7856
- /** Spawn `holocron <...args>` inheriting stdio; resolve with its exit code. */
7857
- function spawnChild(args) {
8688
+ /**
8689
+ * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8690
+ * its handler knows this specific `--dry-run` came from `launchMenu`'s own
8691
+ * preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
8692
+ * A handler that supports it uses this — never bare `argv.dryRun` — to
8693
+ * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
8694
+ * only has special meaning to `launchMenu` itself, so a direct/scripted
8695
+ * `--dry-run` call must never produce it — doing so unconditionally would
8696
+ * silently change the public exit-code contract for a successful dry run.
8697
+ */
8698
+ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8699
+ /**
8700
+ * Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
8701
+ *
8702
+ * `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
8703
+ * update-notifier tail still runs after this resolves (see `launchMenu`'s
8704
+ * docstring), so without this the freshly-spawned child independently runs
8705
+ * its own `checkForUpdates()` too and the notice prints twice: once from
8706
+ * the child right after its command output, once more from the parent
8707
+ * once the child exits. `extraEnv` layers on top — used only for {@link
8708
+ * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8709
+ */
8710
+ function spawnChild(args, extraEnv) {
7858
8711
  return new Promise((resolve) => {
7859
- const child = spawn(process.execPath, [process.argv[1], ...args], { stdio: "inherit" });
8712
+ const child = spawn(process.execPath, [process.argv[1], ...args], {
8713
+ stdio: "inherit",
8714
+ env: {
8715
+ ...process.env,
8716
+ NO_UPDATE_NOTIFIER: "1",
8717
+ ...extraEnv
8718
+ }
8719
+ });
7860
8720
  child.on("exit", (code) => resolve(code ?? 0));
7861
8721
  child.on("error", () => resolve(1));
7862
8722
  });
7863
8723
  }
7864
8724
  /**
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").
8725
+ * Whether `launchMenu` should auto-preview a picked command before running
8726
+ * it for real — only when the command's handler actually honours
8727
+ * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8728
+ * invocation didn't pass `--skip-dry-run` to opt out of the preview
8729
+ * entirely (an escape hatch for "I already know what I want, skip
8730
+ * straight to the real run").
8731
+ */
8732
+ function shouldPreviewDryRun(entry, parentArgv) {
8733
+ return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8734
+ }
8735
+ /**
8736
+ * Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
8737
+ * `[]` for the top), prompt for the picked command's required positionals, spawn
8738
+ * it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
8739
+ * the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
8740
+ * parent's own telemetry flush / update-notifier tail still runs. The parent's
8741
+ * own `command_completed` event fires with command name "unknown" (the middleware
8742
+ * ran before any command was picked) — left as-is rather than suppressed; it's a
8743
+ * real, useful signal ("the menu got used").
8744
+ *
8745
+ * Picking a group opens its submenu, which has a "← back" item; a command ends
8746
+ * the walk. See {@link menuItems} for how a level is built.
8747
+ *
8748
+ * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8749
+ * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8750
+ * this always previews first — a dry run doesn't mutate anything, so
8751
+ * there's nothing to ask permission for. A preview that exits non-zero
8752
+ * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8753
+ * would change) stops here too, skipping the follow-up question since the
8754
+ * real run would have nothing to do either. Otherwise it asks "proceed
8755
+ * with the real run?" before the real spawn — the one actual decision
8756
+ * left to make, since this one does mutate something.
7873
8757
  */
7874
- async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
8758
+ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
7875
8759
  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);
8760
+ const stack = [startPath];
8761
+ let picked;
8762
+ while (!picked) {
8763
+ const path = stack[stack.length - 1];
8764
+ const items = menuItems(entries, path);
8765
+ if (stack.length > 1) items.push({
8766
+ name: "← back",
8767
+ value: BACK,
8768
+ description: "Return to the previous menu",
8769
+ haystack: "back"
8770
+ });
8771
+ const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
8772
+ if (value === "back") stack.pop();
8773
+ else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
8774
+ else picked = entries.find((e) => e.name === value);
8775
+ }
8776
+ const positionals = await promptForPositionals(picked, {});
8777
+ if (shouldPreviewDryRun(picked, parentArgv)) {
8778
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8779
+ if (dryExit === 2) {
8780
+ process.exitCode = 0;
8781
+ return;
8782
+ }
8783
+ if (dryExit !== 0) {
8784
+ process.exitCode = dryExit;
8785
+ return;
8786
+ }
8787
+ if (!await confirm({
8788
+ message: "Dry run complete — proceed with the real run?",
8789
+ default: false
8790
+ })) {
8791
+ process.exitCode = 0;
8792
+ return;
8793
+ }
8794
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8795
+ return;
8796
+ }
8797
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
7879
8798
  }
7880
8799
  //#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");
8800
+ //#region src/ui/json-view.ts
8801
+ function isContainer(value) {
8802
+ return typeof value === "object" && value !== null;
7887
8803
  }
7888
- function getCachePath() {
7889
- return join(getCacheDir(), "update-check.json");
8804
+ function childrenOf(value) {
8805
+ return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
7890
8806
  }
7891
- function readCache() {
7892
- try {
7893
- return JSON.parse(readFileSync(getCachePath(), "utf8"));
7894
- } catch {
7895
- return null;
7896
- }
8807
+ function scalar(value) {
8808
+ if (typeof value === "string") return chalk.green(JSON.stringify(value));
8809
+ if (typeof value === "number") return chalk.yellow(String(value));
8810
+ if (typeof value === "boolean") return chalk.magenta(String(value));
8811
+ return chalk.dim(String(value === void 0 ? null : value));
7897
8812
  }
7898
- function writeCache(entry) {
7899
- try {
7900
- mkdirSync(getCacheDir(), { recursive: true });
7901
- writeFileSync(getCachePath(), JSON.stringify(entry));
7902
- } catch {}
8813
+ function summary(value) {
8814
+ const n = childrenOf(value).length;
8815
+ const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
8816
+ return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
7903
8817
  }
7904
- async function fetchLatestVersion(channel) {
7905
- try {
7906
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
7907
- if (!res.ok) return null;
7908
- const data = await res.json();
7909
- return data["dist-tags"][channel] ?? data["dist-tags"]["latest"] ?? null;
8818
+ /**
8819
+ * Paths of every container deeper than `depth` levels are left out, so those
8820
+ * render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
8821
+ */
8822
+ function expandToDepth(value, depth) {
8823
+ const out = /* @__PURE__ */ new Set();
8824
+ const walk = (v, path, level) => {
8825
+ if (!isContainer(v) || level >= depth) return;
8826
+ out.add(path);
8827
+ for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
8828
+ };
8829
+ walk(value, "", 0);
8830
+ return out;
8831
+ }
8832
+ /** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
8833
+ function renderTree(value, { expanded }) {
8834
+ const lines = [];
8835
+ const walk = (v, path, level, label, trailing) => {
8836
+ const indent = " ".repeat(level);
8837
+ const prefix = `${indent}${label}`;
8838
+ if (!isContainer(v)) {
8839
+ lines.push({
8840
+ path,
8841
+ text: `${prefix}${scalar(v)}${trailing}`,
8842
+ foldable: false,
8843
+ expanded: false,
8844
+ level
8845
+ });
8846
+ return;
8847
+ }
8848
+ const kids = childrenOf(v);
8849
+ const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
8850
+ if (kids.length === 0) {
8851
+ lines.push({
8852
+ path,
8853
+ text: `${prefix}${open}${close}${trailing}`,
8854
+ foldable: false,
8855
+ expanded: false,
8856
+ level
8857
+ });
8858
+ return;
8859
+ }
8860
+ if (!expanded.has(path)) {
8861
+ lines.push({
8862
+ path,
8863
+ text: `${prefix}${summary(v)}${trailing}`,
8864
+ foldable: true,
8865
+ expanded: false,
8866
+ level
8867
+ });
8868
+ return;
8869
+ }
8870
+ lines.push({
8871
+ path,
8872
+ text: `${prefix}${open}`,
8873
+ foldable: true,
8874
+ expanded: true,
8875
+ level
8876
+ });
8877
+ kids.forEach(([k, child], i) => {
8878
+ const comma = i < kids.length - 1 ? "," : "";
8879
+ const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
8880
+ walk(child, `${path}/${k}`, level + 1, childLabel, comma);
8881
+ });
8882
+ lines.push({
8883
+ path: `${path}#close`,
8884
+ text: `${indent}${close}${trailing}`,
8885
+ foldable: false,
8886
+ expanded: false,
8887
+ level
8888
+ });
8889
+ };
8890
+ walk(value, "", 0, "", "");
8891
+ return lines;
8892
+ }
8893
+ /** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
8894
+ function reduceKey(state, key, value, rows) {
8895
+ const lines = renderTree(value, { expanded: state.expanded });
8896
+ const cur = lines[state.cursor];
8897
+ let { cursor } = state;
8898
+ const expanded = new Set(state.expanded);
8899
+ switch (key) {
8900
+ case "up":
8901
+ cursor = Math.max(0, cursor - 1);
8902
+ break;
8903
+ case "down":
8904
+ cursor = Math.min(lines.length - 1, cursor + 1);
8905
+ break;
8906
+ case "right":
8907
+ if (cur?.foldable) expanded.add(cur.path);
8908
+ break;
8909
+ case "left":
8910
+ if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
8911
+ else if (cur) {
8912
+ const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
8913
+ if (parent >= 0) cursor = parent;
8914
+ }
8915
+ break;
8916
+ case "expandAll": return {
8917
+ cursor,
8918
+ top: state.top,
8919
+ expanded: expandToDepth(value, Infinity)
8920
+ };
8921
+ case "collapseAll": return {
8922
+ cursor: 0,
8923
+ top: 0,
8924
+ expanded: /* @__PURE__ */ new Set()
8925
+ };
8926
+ }
8927
+ const count = renderTree(value, { expanded }).length;
8928
+ cursor = Math.min(cursor, count - 1);
8929
+ let top = state.top;
8930
+ if (cursor < top) top = cursor;
8931
+ if (cursor >= top + rows) top = cursor - rows + 1;
8932
+ return {
8933
+ cursor,
8934
+ top: Math.max(0, top),
8935
+ expanded
8936
+ };
8937
+ }
8938
+ const KEYMAP = {
8939
+ "\x1B[A": "up",
8940
+ k: "up",
8941
+ "\x1B[B": "down",
8942
+ j: "down",
8943
+ "\x1B[C": "right",
8944
+ l: "right",
8945
+ "\r": "right",
8946
+ " ": "right",
8947
+ "\x1B[D": "left",
8948
+ h: "left",
8949
+ E: "expandAll",
8950
+ C: "collapseAll",
8951
+ q: "quit",
8952
+ "\x1B": "quit",
8953
+ "": "quit"
8954
+ };
8955
+ function draw(out, value, state, rows) {
8956
+ const lines = renderTree(value, { expanded: state.expanded });
8957
+ const view = lines.slice(state.top, state.top + rows).map((l, i) => {
8958
+ const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
8959
+ return state.top + i === state.cursor ? chalk.inverse(row) : row;
8960
+ });
8961
+ const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
8962
+ out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
8963
+ }
8964
+ async function runInteractive(value, depth) {
8965
+ const { stdin, stdout } = process;
8966
+ if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
8967
+ const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
8968
+ let state = {
8969
+ cursor: 0,
8970
+ top: 0,
8971
+ expanded: expandToDepth(value, depth)
8972
+ };
8973
+ const wasRaw = stdin.isRaw;
8974
+ stdin.setRawMode(true);
8975
+ stdin.resume();
8976
+ stdout.write("\x1B[?1049h\x1B[?25l");
8977
+ const redraw = () => draw(stdout, value, state, rows());
8978
+ stdout.on("resize", redraw);
8979
+ try {
8980
+ redraw();
8981
+ await new Promise((resolve) => {
8982
+ const onData = (buf) => {
8983
+ const key = KEYMAP[buf.toString()];
8984
+ if (key === "quit") {
8985
+ stdin.off("data", onData);
8986
+ resolve();
8987
+ return;
8988
+ }
8989
+ if (key) {
8990
+ state = reduceKey(state, key, value, rows());
8991
+ redraw();
8992
+ }
8993
+ };
8994
+ stdin.on("data", onData);
8995
+ });
8996
+ } finally {
8997
+ stdout.off("resize", redraw);
8998
+ stdout.write("\x1B[?25h\x1B[?1049l");
8999
+ stdin.setRawMode(wasRaw);
9000
+ stdin.pause();
9001
+ }
9002
+ }
9003
+ /**
9004
+ * Print `value` as JSON. Non-TTY stdout always gets plain, valid
9005
+ * `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
9006
+ * coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
9007
+ */
9008
+ async function viewJson(value, options = {}) {
9009
+ const depth = options.depth ?? Infinity;
9010
+ if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
9011
+ if (!process.stdout.isTTY) {
9012
+ console.log(JSON.stringify(value, null, 2));
9013
+ return;
9014
+ }
9015
+ const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
9016
+ console.log(lines.map((l) => l.text).join("\n"));
9017
+ }
9018
+ //#endregion
9019
+ //#region src/ui/open-path.ts
9020
+ /** Platform-appropriate command to open a path in the OS's file browser. */
9021
+ function openCommand(path) {
9022
+ if (process.platform === "darwin") return {
9023
+ cmd: "open",
9024
+ args: [path]
9025
+ };
9026
+ if (process.platform === "win32") return {
9027
+ cmd: "cmd",
9028
+ args: [
9029
+ "/c",
9030
+ "start",
9031
+ "",
9032
+ path
9033
+ ]
9034
+ };
9035
+ return {
9036
+ cmd: "xdg-open",
9037
+ args: [path]
9038
+ };
9039
+ }
9040
+ /**
9041
+ * Open `path` in the OS's default file browser (Finder / Explorer /
9042
+ * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
9043
+ * it outlives this process, and never throws when the opener binary isn't
9044
+ * on PATH (e.g. no `xdg-open` in a minimal container).
9045
+ */
9046
+ function openPath(path) {
9047
+ const { cmd, args } = openCommand(path);
9048
+ const child = spawn(cmd, args, {
9049
+ stdio: "ignore",
9050
+ detached: true
9051
+ });
9052
+ child.on("error", () => {});
9053
+ child.unref();
9054
+ }
9055
+ //#endregion
9056
+ //#region src/update-notifier.ts
9057
+ const PACKAGE_NAME = "@theholocron/cli";
9058
+ const CACHE_TTL_MS = 864e5;
9059
+ const FETCH_TIMEOUT_MS = 3e3;
9060
+ function getCacheDir() {
9061
+ return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
9062
+ }
9063
+ function getCachePath() {
9064
+ return join(getCacheDir(), "update-check.json");
9065
+ }
9066
+ function readCache() {
9067
+ try {
9068
+ return JSON.parse(readFileSync(getCachePath(), "utf8"));
9069
+ } catch {
9070
+ return null;
9071
+ }
9072
+ }
9073
+ function writeCache(entry) {
9074
+ try {
9075
+ mkdirSync(getCacheDir(), { recursive: true });
9076
+ writeFileSync(getCachePath(), JSON.stringify(entry));
9077
+ } catch {}
9078
+ }
9079
+ async function fetchLatestVersion(channel) {
9080
+ try {
9081
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
9082
+ if (!res.ok) return null;
9083
+ const data = await res.json();
9084
+ return data["dist-tags"][channel] ?? data["dist-tags"]["latest"] ?? null;
7910
9085
  } catch {
7911
9086
  return null;
7912
9087
  }
@@ -8021,6 +9196,19 @@ function resolveOrg(argv, config) {
8021
9196
  return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
8022
9197
  }
8023
9198
  /**
9199
+ * Print the dim version line once per terminal session — keyed by the
9200
+ * parent shell's PID, since that's stable across repeated bare `holocron`
9201
+ * invocations in the same shell but changes in a new tab/window. A marker
9202
+ * file in the OS tmpdir tracks whether this shell has already seen it, so
9203
+ * re-running `holocron` doesn't repeat the line every time.
9204
+ */
9205
+ function printVersionHeaderOnce() {
9206
+ const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
9207
+ if (existsSync(marker)) return;
9208
+ console.log(style.dim(`v${CLI_VERSION}`));
9209
+ writeFileSync(marker, "");
9210
+ }
9211
+ /**
8024
9212
  * `buildCliLogger` options derived from a loaded `holocron.config` — the
8025
9213
  * level, the Axiom dataset, and the resolved org for a keyring-backed
8026
9214
  * Axiom token. Used by every handler that has already called `loadConfig`.
@@ -8050,23 +9238,31 @@ function tokenContext(rawTokens) {
8050
9238
  return parseTokenArgs(rawTokens);
8051
9239
  } catch (err) {
8052
9240
  if (err instanceof TokenParseError) {
8053
- getLogger().error(`--token: ${err.message}`);
9241
+ reportError(`--token: ${err.message}`);
8054
9242
  process.exitCode = 1;
8055
9243
  return null;
8056
9244
  }
8057
9245
  throw err;
8058
9246
  }
8059
9247
  }
9248
+ /** Layer 2: a bare group command (`holocron repo`, `holocron repo upgrade`, …) opens the menu at that level. */
9249
+ async function launchGroupMenu(parent, argv) {
9250
+ await launchMenu(COMMAND_REGISTRY, argv, `${parent} — choose a subcommand:`, `Run \`holocron ${parent} --help\` to see available ${parent} subcommands.`, parent.split(" "));
9251
+ }
8060
9252
  init(CLI_VERSION);
8061
9253
  const updateCheckPromise = checkForUpdates(CLI_VERSION);
8062
9254
  let finishCommand = () => {};
8063
9255
  /** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
8064
9256
  let errorReported = false;
8065
9257
  try {
8066
- await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
9258
+ await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").version(CLI_VERSION).alias("version", "v").parserConfiguration({ "populate--": true }).option("dry-run", {
8067
9259
  type: "boolean",
8068
9260
  default: false,
8069
9261
  describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
9262
+ }).option("skip-dry-run", {
9263
+ type: "boolean",
9264
+ default: false,
9265
+ describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
8070
9266
  }).option("token", {
8071
9267
  type: "string",
8072
9268
  array: true,
@@ -8090,20 +9286,21 @@ try {
8090
9286
  type: "boolean",
8091
9287
  default: false,
8092
9288
  describe: "Set the log level to error — suppress info and warn."
9289
+ }).option("log", {
9290
+ type: "string",
9291
+ choices: LOG_LEVELS,
9292
+ 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
9293
  }).middleware((argv) => {
8094
- const name = argv._.slice(0, 2).join(" ") || "unknown";
9294
+ const name = argv._.slice(0, 3).join(" ") || "unknown";
8095
9295
  printRunId = Boolean(argv.debug || argv.verbose);
8096
9296
  buildCliLogger(argv, {
8097
9297
  command: name,
8098
9298
  org: argv.org
8099
9299
  });
8100
9300
  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", {
9301
+ }).command("clone [target]", "Clone a single repo (org/repo) or every repo in an org (org)", (y) => y.positional("target", {
8104
9302
  type: "string",
8105
- demandOption: true,
8106
- describe: "GitHub org to clone (e.g., theholocron)"
9303
+ describe: "org/repo to clone, or a bare org to clone every repo in it (confirms first)."
8107
9304
  }).option("dir", {
8108
9305
  type: "string",
8109
9306
  describe: "Parent directory to clone into (default: ~/Code/<org>)"
@@ -8114,16 +9311,39 @@ try {
8114
9311
  try {
8115
9312
  token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
8116
9313
  } catch (err) {
8117
- getLogger().error(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
9314
+ reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8118
9315
  process.exitCode = 1;
8119
9316
  return;
8120
9317
  }
8121
- if ((await runClone({
8122
- org: argv.org,
9318
+ const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
9319
+ const { org, repo } = parseCloneTarget(targetInput);
9320
+ const report = await runClone({
9321
+ org,
9322
+ ...repo ? { repo } : {},
8123
9323
  token,
8124
9324
  dryRun: argv.dryRun,
8125
- ...argv.dir ? { dir: argv.dir } : {}
8126
- })).status === "fail") process.exitCode = 1;
9325
+ ...argv.dir ? { dir: argv.dir } : {},
9326
+ ...!repo ? { confirmWholeOrg: async ({ total, alreadyCloned, toClone }) => {
9327
+ 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`);
9328
+ if (toClone === 0) return true;
9329
+ return confirm({
9330
+ message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
9331
+ default: false
9332
+ });
9333
+ } } : {},
9334
+ confirmOpen: async (openTarget, moreToCome) => {
9335
+ if (!process.stdin.isTTY) return;
9336
+ if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
9337
+ if (await confirm({
9338
+ message: `Open ${openTarget}?`,
9339
+ default: false
9340
+ })) openPath(openTarget);
9341
+ }
9342
+ });
9343
+ if (report.status === "fail") {
9344
+ if (report.message) reportError(`clone: ${report.message}`);
9345
+ process.exitCode = 1;
9346
+ } else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
8127
9347
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
8128
9348
  type: "string",
8129
9349
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -8142,28 +9362,6 @@ try {
8142
9362
  org: resolveOrg(argv, loaded.resolved)
8143
9363
  }
8144
9364
  })).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
9365
  }).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
9366
  await runSkillsInstall({
8169
9367
  loaded: await loadConfig(argv.cwd),
@@ -8196,331 +9394,214 @@ try {
8196
9394
  ...argv.name ? { name: argv.name } : {}
8197
9395
  }).status === "fail") process.exitCode = 1;
8198
9396
  }).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", {
9397
+ await launchGroupMenu("skills", argv);
9398
+ }), () => {}).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
9399
  type: "string",
8202
- describe: "Secret name (e.g., NPM_TOKEN)"
8203
- }).positional("value", {
9400
+ demandOption: true,
9401
+ describe: "Task name (test, typecheck, lint, build, audit, or `ci` for the merge-gating suite). See `holocron run --help`."
9402
+ }).positional("job", {
8204
9403
  type: "string",
8205
- describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
8206
- }).option("from-stdin", {
9404
+ describe: "Sub-job within the task (e.g. `holocron run audit performance`, `holocron run lint commit-msg <file>`). Omit to run every job."
9405
+ }).positional("passthrough", {
9406
+ type: "string",
9407
+ array: true,
9408
+ describe: "Args forwarded to the tool — put them after `--`."
9409
+ }).option("required", {
8207
9410
  type: "boolean",
8208
9411
  default: false,
8209
- describe: "Read the secret value from stdin"
8210
- }).option("from-env", {
9412
+ describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
9413
+ }).option("all", {
9414
+ type: "boolean",
9415
+ default: false,
9416
+ describe: "With `run ci`: run every `ci: true` task, not just the required ones."
9417
+ }).option("filter", {
8211
9418
  type: "string",
8212
- describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
8213
- }).option("scope", {
9419
+ describe: "turbo --filter=<pkg> passthrough (monorepo)."
9420
+ }), async (argv) => {
9421
+ const { logger } = buildCliLogger(argv, { command: argv.task === "ci" ? "run ci" : argv.task === "lint" && argv.job === "commit-msg" ? "run lint commit-msg" : "run" });
9422
+ const passthrough = [...argv.passthrough ?? [], ...argv["--"] ?? []];
9423
+ if (argv.task === "lint" && argv.job === "commit-msg") {
9424
+ const file = passthrough[0];
9425
+ if (!file) {
9426
+ reportError("run lint commit-msg: missing <file> (the commit message file git passes as $1).");
9427
+ process.exitCode = 1;
9428
+ return;
9429
+ }
9430
+ const result = await lintCommitMsgFile(file, argv.cwd);
9431
+ if (!result.valid) {
9432
+ for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
9433
+ process.exitCode = 1;
9434
+ }
9435
+ return;
9436
+ }
9437
+ if (argv.task === "ci") {
9438
+ const ciConfig = await loadTasksConfig(argv.cwd).catch(() => void 0);
9439
+ if (createAstromech({
9440
+ cwd: argv.cwd,
9441
+ logger,
9442
+ config: ciConfig
9443
+ }).ci({
9444
+ dryRun: argv.dryRun,
9445
+ scope: argv.all ? "all" : "required",
9446
+ ...argv.filter ? { filter: argv.filter } : {}
9447
+ }).status === "fail") process.exitCode = 1;
9448
+ return;
9449
+ }
9450
+ const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
9451
+ const report = createAstromech({
9452
+ cwd: argv.cwd,
9453
+ logger,
9454
+ config
9455
+ }).run(argv.task, {
9456
+ ...argv.job !== void 0 ? { job: argv.job } : {},
9457
+ passthrough,
9458
+ dryRun: argv.dryRun,
9459
+ required: argv.required,
9460
+ ...argv.filter ? { filter: argv.filter } : {}
9461
+ });
9462
+ if (report.status === "fail" || report.status === "unknown") process.exitCode = 1;
9463
+ }).command("config show", "Print the resolved holocron config", (y) => y.option("depth", {
9464
+ type: "number",
9465
+ describe: "Collapse objects/arrays nested deeper than N levels (TTY output only)"
9466
+ }).option("interactive", {
9467
+ type: "boolean",
9468
+ describe: "Browse the config in a foldable tree (default on a TTY; --no-interactive for static output)"
9469
+ }), async (argv) => {
9470
+ await viewJson((await loadConfig(argv.cwd)).resolved, {
9471
+ depth: argv.depth,
9472
+ interactive: argv.interactive
9473
+ });
9474
+ }).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) => {
9475
+ const [provider] = await promptForPositionals(getEntry("auth set"), argv);
9476
+ if ((await runAuthSet({
9477
+ provider,
9478
+ ...argv.value ? { positional: argv.value } : {},
9479
+ ...argv.org ? { org: argv.org } : {}
9480
+ })).status === "fail") process.exitCode = 1;
9481
+ }).command("unset [provider]", "Remove a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
9482
+ const [provider] = await promptForPositionals(getEntry("auth unset"), argv);
9483
+ runAuthUnset({ provider });
9484
+ }).command("check [provider]", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
9485
+ const [provider] = await promptForPositionals(getEntry("auth check"), argv);
9486
+ if ((await runAuthCheck({
9487
+ provider,
9488
+ ...argv.org ? { org: argv.org } : {}
9489
+ })).status === "fail") process.exitCode = 1;
9490
+ }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
9491
+ await runAuthList();
9492
+ }).command("$0", false, () => {}, async (argv) => {
9493
+ await launchGroupMenu("auth", argv);
9494
+ }), () => {}).command("$0", false, () => {}, async (argv) => {
9495
+ printVersionHeaderOnce();
9496
+ const runEntries = buildRunMenuEntries(await listRunnableTasks(argv));
9497
+ await launchMenu([...COMMAND_REGISTRY, ...runEntries], argv);
9498
+ }).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
9499
  type: "string",
8215
- default: "repo",
8216
- describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
9500
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
9501
+ }).option("hooks", {
9502
+ type: "boolean",
9503
+ describe: "Install git hooks (.husky/pre-push → holocron run ci). Default: on for protection:'strict'. --no-hooks to skip."
8217
9504
  }), async (argv) => {
8218
9505
  const tokens = tokenContext(argv.token);
8219
9506
  if (!tokens) return;
8220
- const [name] = await promptForPositionals(getEntry("secret set"), argv);
8221
- const scopeArg = argv.scope;
8222
- const scope = parseScope(scopeArg);
8223
9507
  const loaded = await loadConfig(argv.cwd);
8224
9508
  applyResolvedConfig(argv, loaded.resolved);
8225
- if ((await runSecretSet({
9509
+ if ((await runSetup({
8226
9510
  loaded,
8227
9511
  context: {
8228
9512
  repoRoot: argv.cwd,
8229
9513
  dryRun: argv.dryRun,
9514
+ ...argv.repo ? { repo: argv.repo } : {},
8230
9515
  ...tokens,
8231
9516
  org: resolveOrg(argv, loaded.resolved)
8232
9517
  },
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."
9518
+ ...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
9519
+ })).summary.fail > 0) process.exitCode = 1;
9520
+ }).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", {
9521
+ type: "boolean",
9522
+ describe: "Print what would change without writing",
9523
+ default: false
8249
9524
  }), async (argv) => {
8250
- const tokens = tokenContext(argv.token);
8251
- if (!tokens) return;
8252
- const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
8253
9525
  const loaded = await loadConfig(argv.cwd);
8254
9526
  applyResolvedConfig(argv, loaded.resolved);
8255
- if ((await runSecretsSync({
9527
+ const report = await runSyncReadme({
8256
9528
  loaded,
8257
9529
  context: {
8258
9530
  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", {
9531
+ dryRun: argv.dryRun
9532
+ }
9533
+ });
9534
+ if (report.status === "fail") {
9535
+ if (report.message) reportError(`repo sync readme: ${report.message}`);
9536
+ process.exitCode = 1;
9537
+ }
9538
+ }).positional("steps", {
8271
9539
  type: "string",
8272
- demandOption: true,
8273
- describe: "Deployment project id (e.g., Vercel prj_*)"
8274
- }).option("target", {
9540
+ array: true,
9541
+ describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
9542
+ }).option("repo", {
8275
9543
  type: "string",
8276
- choices: ["production", "staging"],
8277
- describe: "Named environment to deploy into. Omit for a branch preview."
9544
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
8278
9545
  }), async (argv) => {
8279
9546
  const tokens = tokenContext(argv.token);
8280
9547
  if (!tokens) return;
8281
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8282
9548
  const loaded = await loadConfig(argv.cwd);
8283
9549
  applyResolvedConfig(argv, loaded.resolved);
8284
- if ((await runDeploy({
9550
+ if ((await runSync({
8285
9551
  loaded,
8286
9552
  context: {
8287
9553
  repoRoot: argv.cwd,
8288
9554
  dryRun: argv.dryRun,
9555
+ ...argv.repo ? { repo: argv.repo } : {},
8289
9556
  ...tokens,
8290
9557
  org: resolveOrg(argv, loaded.resolved)
8291
9558
  },
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", {
9559
+ ...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
9560
+ })).summary.fail > 0) process.exitCode = 1;
9561
+ }).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
9562
  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)."
9563
+ describe: "Target Node.js major version (e.g., 22)"
9564
+ }).option("from", {
9565
+ type: "number",
9566
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
8433
9567
  }), async (argv) => {
8434
- const { logger } = buildCliLogger(argv, { command: "run" });
8435
- const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
8436
- const report = createAstromech({
9568
+ const [to] = await promptForPositionals(getEntry("repo upgrade node"), argv);
9569
+ let extra = [];
9570
+ try {
9571
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
9572
+ const extraRaw = JSON.parse(raw).upgrade?.node?.extra;
9573
+ if (Array.isArray(extraRaw)) extra = extraRaw;
9574
+ } catch {}
9575
+ const report = await runUpgradeNode({
9576
+ to: Number(to),
9577
+ ...argv.from != null ? { from: argv.from } : {},
8437
9578
  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
9579
  dryRun: argv.dryRun,
8444
- required: argv.required,
8445
- ...argv.filter ? { filter: argv.filter } : {}
9580
+ extra
8446
9581
  });
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", {
9582
+ if (report.status === "fail") {
9583
+ if (report.message) reportError(`upgrade node: ${report.message}`);
9584
+ process.exitCode = 1;
9585
+ }
9586
+ }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
8449
9587
  type: "boolean",
8450
9588
  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)."
9589
+ describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
8455
9590
  }), async (argv) => {
8456
- const { logger } = buildCliLogger(argv, { command: "ci" });
8457
- const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
8458
- if (createAstromech({
9591
+ const report = await runUpgradeDeps({
8459
9592
  cwd: argv.cwd,
8460
- logger,
8461
- config
8462
- }).ci({
8463
9593
  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)}`);
9594
+ pinsOnly: argv.pinsOnly
9595
+ });
9596
+ if (report.status === "fail") {
9597
+ if (report.message) reportError(`upgrade deps: ${report.message}`);
8494
9598
  process.exitCode = 1;
8495
- return;
8496
9599
  }
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", {
9600
+ }).command("$0", false, () => {}, async (argv) => {
9601
+ await launchGroupMenu("repo upgrade", argv);
9602
+ }), () => {}).command("$0", false, () => {}, async (argv) => {
9603
+ await launchGroupMenu("repo", argv);
9604
+ }), () => {}).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
9605
  type: "string",
8525
9606
  describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
8526
9607
  }).positional("name", {
@@ -8569,7 +9650,7 @@ try {
8569
9650
  }).option("verify", {
8570
9651
  type: "boolean",
8571
9652
  default: true,
8572
- describe: "Run pnpm install + holocron setup after bootstrapping (--no-verify skips)"
9653
+ describe: "Run pnpm install + holocron repo setup after bootstrapping (--no-verify skips)"
8573
9654
  }), async (argv) => {
8574
9655
  try {
8575
9656
  const tokens = tokenContext(argv.token);
@@ -8745,12 +9826,12 @@ try {
8745
9826
  }) === "yes";
8746
9827
  if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
8747
9828
  if (!type) {
8748
- getLogger().error("new: template type is required");
9829
+ reportError("new: template type is required");
8749
9830
  process.exitCode = 1;
8750
9831
  return;
8751
9832
  }
8752
9833
  if (!name) {
8753
- getLogger().error("new: repo name is required");
9834
+ reportError("new: repo name is required");
8754
9835
  process.exitCode = 1;
8755
9836
  return;
8756
9837
  }
@@ -8779,13 +9860,13 @@ try {
8779
9860
  })).status === "fail") process.exitCode = 1;
8780
9861
  } catch (err) {
8781
9862
  if (err instanceof NewError) {
8782
- getLogger().error(`new: ${err.message}`);
9863
+ reportError(`new: ${err.message}`);
8783
9864
  process.exitCode = 1;
8784
9865
  return;
8785
9866
  }
8786
9867
  throw err;
8787
9868
  }
8788
- }).command("plugin create [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
9869
+ }).command("plugin [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
8789
9870
  type: "string",
8790
9871
  describe: "Package slug (kebab-case)"
8791
9872
  }).positional("vendor", {
@@ -8809,7 +9890,7 @@ try {
8809
9890
  describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
8810
9891
  }), async (argv) => {
8811
9892
  try {
8812
- const [slug, vendor] = await promptForPositionals(getEntry("plugin create"), argv);
9893
+ const [slug, vendor] = await promptForPositionals(getEntry("new plugin"), argv);
8813
9894
  const { capability, vendorEnv, baseUrl } = await resolvePluginCreateInputs({
8814
9895
  capability: argv.capability,
8815
9896
  vendorEnv: argv.vendorEnv,
@@ -8838,92 +9919,315 @@ try {
8838
9919
  }).status === "fail") process.exitCode = 1;
8839
9920
  } catch (err) {
8840
9921
  if (err instanceof PluginCreateError) {
8841
- getLogger().error(`plugin create: ${err.message}`);
9922
+ reportError(`plugin create: ${err.message}`);
8842
9923
  process.exitCode = 1;
8843
9924
  return;
8844
9925
  }
8845
9926
  throw err;
8846
9927
  }
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."
9928
+ }).command("$0", false, () => {}, async (argv) => {
9929
+ await launchGroupMenu("new", argv);
9930
+ }), () => {}).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", {
9931
+ type: "string",
9932
+ describe: "Secret name (e.g., NPM_TOKEN)"
9933
+ }).positional("value", {
9934
+ type: "string",
9935
+ describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
9936
+ }).option("from-stdin", {
9937
+ type: "boolean",
9938
+ default: false,
9939
+ describe: "Read the secret value from stdin"
9940
+ }).option("from-env", {
9941
+ type: "string",
9942
+ describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
9943
+ }).option("scope", {
9944
+ type: "string",
9945
+ default: "repo",
9946
+ describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
8853
9947
  }), 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}`);
9948
+ const tokens = tokenContext(argv.token);
9949
+ if (!tokens) return;
9950
+ const [name] = await promptForPositionals(getEntry("secrets set"), argv);
9951
+ const scopeArg = argv.scope;
9952
+ const scope = parseScope(scopeArg);
9953
+ const loaded = await loadConfig(argv.cwd);
9954
+ applyResolvedConfig(argv, loaded.resolved);
9955
+ if ((await runSecretSet({
9956
+ loaded,
9957
+ context: {
9958
+ repoRoot: argv.cwd,
9959
+ dryRun: argv.dryRun,
9960
+ ...tokens,
9961
+ org: resolveOrg(argv, loaded.resolved)
9962
+ },
9963
+ name,
9964
+ ...argv.value ? { value: argv.value } : {},
9965
+ ...argv.fromStdin ? { fromStdin: true } : {},
9966
+ ...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
9967
+ scope
9968
+ })).status === "fail") process.exitCode = 1;
9969
+ }).command("sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
9970
+ type: "string",
9971
+ describe: "Vault environment id to read (1P Environment id, etc.)"
9972
+ }).option("project-id", {
9973
+ type: "string",
9974
+ describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
9975
+ }).option("target", {
9976
+ type: "array",
9977
+ default: ["production", "preview"],
9978
+ describe: "Deployment targets to sync to. Defaults to production + preview."
9979
+ }).option("github-secret", {
9980
+ type: "array",
9981
+ describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
9982
+ }).option("github-secret-scope", {
9983
+ type: "string",
9984
+ default: "repo",
9985
+ describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
9986
+ }), async (argv) => {
9987
+ const tokens = tokenContext(argv.token);
9988
+ if (!tokens) return;
9989
+ const githubSecretScope = parseScope(argv.githubSecretScope);
9990
+ const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
9991
+ const loaded = await loadConfig(argv.cwd);
9992
+ applyResolvedConfig(argv, loaded.resolved);
9993
+ if ((await runSecretsSync({
9994
+ loaded,
9995
+ context: {
9996
+ repoRoot: argv.cwd,
9997
+ dryRun: argv.dryRun,
9998
+ ...tokens,
9999
+ org: resolveOrg(argv, loaded.resolved)
10000
+ },
10001
+ environmentId,
10002
+ ...argv.projectId ? { projectId: argv.projectId } : {},
10003
+ ...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
10004
+ githubSecretScope,
10005
+ targets: argv.target
10006
+ })).summary.fail > 0) process.exitCode = 1;
10007
+ }).command("$0", false, () => {}, async (argv) => {
10008
+ await launchGroupMenu("secrets", argv);
10009
+ }), () => {}).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", {
10010
+ type: "string",
10011
+ default: "theholocron/.github",
10012
+ describe: "Target org/repo (default: theholocron/.github)"
10013
+ }).option("branch", {
10014
+ type: "string",
10015
+ describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
10016
+ }).option("pr", {
10017
+ type: "boolean",
10018
+ default: false,
10019
+ describe: "Open a PR after pushing to --branch (no-op without --branch)"
10020
+ }).option("message", {
10021
+ type: "string",
10022
+ describe: "Commit message (default: chore: sync from theholocron/holocron)"
10023
+ }).option("output-dir", {
10024
+ type: "string",
10025
+ describe: "Write generated files to this local directory instead of pushing (for validation)"
10026
+ }), async (argv) => {
10027
+ const outputDir = argv["output-dir"];
10028
+ const parsed = tokenContext(argv.token);
10029
+ if (!parsed) return;
10030
+ let token;
10031
+ if (outputDir) token = "no-token-needed";
10032
+ else try {
10033
+ token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
10034
+ } catch (err) {
10035
+ reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
8870
10036
  process.exitCode = 1;
10037
+ return;
8871
10038
  }
8872
- }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
10039
+ if ((await runSyncGithub({
10040
+ token,
10041
+ repo: argv.repo,
10042
+ dryRun: argv.dryRun,
10043
+ ...argv.branch ? { branch: argv.branch } : {},
10044
+ ...argv.pr ? { createPr: true } : {},
10045
+ ...argv.message ? { message: argv.message } : {},
10046
+ ...outputDir ? { outputDir } : {}
10047
+ })).status === "fail") process.exitCode = 1;
10048
+ }).command("$0", false, () => {}, async (argv) => {
10049
+ await launchGroupMenu("sync", argv);
10050
+ }), () => {}).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", {
10051
+ type: "string",
10052
+ describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
10053
+ }), async (argv) => {
10054
+ const [newVersion] = await promptForPositionals(getEntry("package bump-versions"), argv);
10055
+ if ((await runNpmBumpVersions({
10056
+ version: newVersion,
10057
+ cwd: argv.cwd,
10058
+ dryRun: argv.dryRun
10059
+ })).status === "fail") process.exitCode = 1;
10060
+ }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
8873
10061
  type: "boolean",
8874
10062
  default: false,
8875
- describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
10063
+ 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."
10064
+ }).option("tag", {
10065
+ type: "string",
10066
+ describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
10067
+ }).option("otp", {
10068
+ type: "string",
10069
+ describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
10070
+ }).option("sync-trust", {
10071
+ type: "array",
10072
+ 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."
10073
+ }).option("repo", {
10074
+ type: "string",
10075
+ describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
10076
+ }).option("provenance", {
10077
+ type: "boolean",
10078
+ default: true,
10079
+ describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
10080
+ }).option("skip-already-published", {
10081
+ type: "boolean",
10082
+ default: false,
10083
+ 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
10084
  }), async (argv) => {
8877
- const report = await runUpgradeDeps({
10085
+ if (argv.syncTrust) {
10086
+ const [oldFile, newFile] = argv.syncTrust;
10087
+ if (!oldFile || !newFile) {
10088
+ reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
10089
+ process.exitCode = 1;
10090
+ return;
10091
+ }
10092
+ if ((await runSyncTrust({
10093
+ cwd: argv.cwd,
10094
+ oldFile,
10095
+ newFile,
10096
+ dryRun: argv.dryRun,
10097
+ ...argv.repo ? { repo: argv.repo } : {}
10098
+ })).status === "fail") process.exitCode = 1;
10099
+ return;
10100
+ }
10101
+ if (argv.initial) {
10102
+ if ((await runPublish({
10103
+ cwd: argv.cwd,
10104
+ dryRun: argv.dryRun,
10105
+ ...argv.tag ? { tag: argv.tag } : {},
10106
+ ...argv.otp ? { otp: argv.otp } : {}
10107
+ })).status === "fail") process.exitCode = 1;
10108
+ return;
10109
+ }
10110
+ if ((await runSteadyPublish({
8878
10111
  cwd: argv.cwd,
8879
10112
  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
- }
10113
+ provenance: argv.provenance,
10114
+ skipAlreadyPublished: argv.skipAlreadyPublished,
10115
+ ...argv.tag ? { tag: argv.tag } : {},
10116
+ ...argv.otp ? { otp: argv.otp } : {}
10117
+ })).status === "fail") process.exitCode = 1;
8886
10118
  }).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 } : {}
10119
+ await launchGroupMenu("package", argv);
10120
+ }), () => {}).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", {
10121
+ type: "number",
10122
+ describe: "PR number to clean up"
10123
+ }).option("project", {
10124
+ type: "string",
10125
+ demandOption: true,
10126
+ describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
10127
+ }).option("repo", {
10128
+ type: "string",
10129
+ describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
10130
+ }), async (argv) => {
10131
+ const tokens = tokenContext(argv.token);
10132
+ if (!tokens) return;
10133
+ const [pr] = await promptForPositionals(getEntry("deploy cleanup-preview"), argv);
10134
+ const loaded = await loadConfig(argv.cwd);
10135
+ applyResolvedConfig(argv, loaded.resolved);
10136
+ if ((await runCleanupPreview({
10137
+ loaded,
10138
+ context: {
10139
+ repoRoot: argv.cwd,
10140
+ dryRun: argv.dryRun,
10141
+ ...tokens,
10142
+ org: resolveOrg(argv, loaded.resolved)
10143
+ },
10144
+ prNumber: Number(pr),
10145
+ project: argv.project,
10146
+ ...argv.repo ? { repo: argv.repo } : {}
8894
10147
  })).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 } : {}
10148
+ }).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", {
10149
+ type: "string",
10150
+ default: "",
10151
+ describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
10152
+ }).option("from", {
10153
+ type: "string",
10154
+ describe: "The previous release's commit, to diff against. Omit when there is none."
10155
+ }).option("to", {
10156
+ type: "string",
10157
+ describe: "The release commit. Default HEAD."
10158
+ }), async (argv) => {
10159
+ if ((await deployOnRelease({
10160
+ cwd: argv.cwd,
10161
+ channel: argv.channel,
10162
+ ...argv.from ? { from: argv.from } : {},
10163
+ ...argv.to ? { to: argv.to } : {},
10164
+ dryRun: argv.dryRun,
10165
+ print: (line) => console.log(line)
10166
+ })).status === "fail") process.exitCode = 1;
10167
+ }).positional("branch", {
10168
+ type: "string",
10169
+ describe: "Git branch to deploy (omit when using --files)"
10170
+ }).option("project-id", {
10171
+ type: "string",
10172
+ describe: "Deployment project id (required to trigger a deployment, e.g. Vercel prj_*)"
10173
+ }).option("target", {
10174
+ type: "string",
10175
+ choices: ["production", "staging"],
10176
+ describe: "Named environment to deploy into. Omit for a branch preview."
10177
+ }).option("files", {
10178
+ type: "string",
10179
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
10180
+ }).conflicts("files", "branch"), async (argv) => {
10181
+ if (!argv.projectId) {
10182
+ reportError("deploy: missing required argument --project-id");
10183
+ process.exitCode = 1;
10184
+ return;
10185
+ }
10186
+ const tokens = tokenContext(argv.token);
10187
+ if (!tokens) return;
10188
+ const loaded = await loadConfig(argv.cwd);
10189
+ applyResolvedConfig(argv, loaded.resolved);
10190
+ const context = {
10191
+ repoRoot: argv.cwd,
10192
+ dryRun: argv.dryRun,
10193
+ ...tokens,
10194
+ org: resolveOrg(argv, loaded.resolved)
10195
+ };
10196
+ if (argv.files) {
10197
+ if ((await runDeployFromFiles({
10198
+ loaded,
10199
+ context,
10200
+ projectId: argv.projectId,
10201
+ dir: argv.files,
10202
+ ...argv.target ? { target: argv.target } : {}
10203
+ })).status === "fail") process.exitCode = 1;
10204
+ return;
10205
+ }
10206
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
10207
+ if ((await runDeploy({
10208
+ loaded,
10209
+ context,
10210
+ projectId: argv.projectId,
10211
+ branch,
10212
+ ...argv.target ? { target: argv.target } : {}
8903
10213
  })).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
10214
  }).strict().help().epilogue(`Execution contexts:
8911
10215
  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
10216
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
8913
10217
  captureException(err);
8914
- getLogger().error(err.message);
10218
+ reportError(err.message);
8915
10219
  errorReported = true;
8916
10220
  process.exitCode = 1;
8917
10221
  return;
8918
10222
  }
8919
10223
  if (err) throw err;
8920
- getLogger().error(msg);
10224
+ reportError(msg);
8921
10225
  errorReported = true;
8922
10226
  process.exitCode = 1;
8923
10227
  }).parse();
8924
10228
  } catch (err) {
8925
10229
  captureException(err);
8926
- if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
10230
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
8927
10231
  if (!process.exitCode) process.exitCode = 1;
8928
10232
  }
8929
10233
  finishCommand(!process.exitCode);