@theholocron/cli 5.0.0-alpha.151 → 5.0.0-alpha.153

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
@@ -4,24 +4,27 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSyn
4
4
  import { homedir, hostname, tmpdir, userInfo } from "node:os";
5
5
  import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
7
- import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
+ import { KNOWN_WORKFLOWS, createAlexignore, createAlexrc, createAstromech, createCommitMsg, createDependabot, createDevmoji, createEditorconfig, createEditorconfigChecker, createHeader, createLabeler, createPreCommit, createPrePush, createPrepareCommitMsg, createYamlignore, createYamllint, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
8
8
  import { loadTasksConfig } from "@theholocron/astromech/config";
9
+ import { lintCommitMsgFile } from "@theholocron/astromech/lint";
9
10
  import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
10
11
  import yargs from "yargs";
11
12
  import { hideBin } from "yargs/helpers";
12
13
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
13
- import { createEnvLookup } from "@theholocron/env-utils";
14
+ import { createEnvLookup, firstEnv, withEnvPrefix } from "@theholocron/env-utils";
14
15
  import { Entry, findCredentials } from "@napi-rs/keyring";
15
16
  import { pathToFileURL } from "node:url";
16
17
  import { errorMessage } from "@theholocron/misc-utils";
18
+ import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
19
+ import { readGitRemote, updateGitignore } from "@theholocron/git-utils";
20
+ import { listWorkspacePackagesSync, readPackageJson, readPackageJsonSync } from "@theholocron/pkg-utils";
17
21
  import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
18
22
  import chalk from "chalk";
19
- import ora from "ora";
23
+ import search from "@inquirer/search";
20
24
  import { execFileSync, spawn, spawnSync } from "node:child_process";
25
+ import ora from "ora";
21
26
  import { findRootSync, walkFilesSync } from "@theholocron/fs-utils";
22
27
  import { stringifyJson } from "@theholocron/json-utils";
23
- import { listWorkspacePackagesSync, readPackageJson, readPackageJsonSync } from "@theholocron/pkg-utils";
24
- import { readGitRemote, updateGitignore } from "@theholocron/git-utils";
25
28
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
26
29
  import { createHash, randomUUID } from "node:crypto";
27
30
  import { generateReadme } from "@theholocron/components-doc/markdown";
@@ -29,14 +32,17 @@ import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getU
29
32
  import { createGitHubClient } from "@theholocron/github-client";
30
33
  import { PostHogSink } from "@theholocron/observability/analytics";
31
34
  import { SentrySink } from "@theholocron/observability/errors";
32
- import load from "@commitlint/load";
33
- import lint from "@commitlint/lint";
34
- import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
35
- import search from "@inquirer/search";
36
- //#region src/env.ts
35
+ //#region src/env/env.ts
37
36
  /** Singleton env for simple global lookups throughout the CLI. */
38
37
  const env = createEnvLookup();
39
38
  /**
39
+ * `get("KEY")` tries `HOLOCRON_KEY` first, then `KEY`. Use it for settings that
40
+ * have both a Holocron-namespaced override and a vendor-native name; do not use
41
+ * it for `HOLOCRON_*` variables that have no bare fallback (`HOLOCRON_ORG`,
42
+ * `HOLOCRON_LOG_LEVEL`, …), where the bare name would be wrong.
43
+ */
44
+ const holocronEnv = withEnvPrefix(env, "HOLOCRON");
45
+ /**
40
46
  * Create an injectable env for commands that accept a fake env in tests.
41
47
  * Pass `input.env` when available, falls back to `process.env`.
42
48
  */
@@ -44,7 +50,7 @@ function makeEnv(source) {
44
50
  return createEnvLookup(source);
45
51
  }
46
52
  //#endregion
47
- //#region src/auth/keyring.ts
53
+ //#region src/auth/keyring/keyring.ts
48
54
  /**
49
55
  * Keyring-backed bootstrap credential store.
50
56
  *
@@ -114,7 +120,7 @@ function listStoredProviders() {
114
120
  }
115
121
  }
116
122
  //#endregion
117
- //#region src/auth/auth-resolver.ts
123
+ //#region src/auth/auth-resolver/auth-resolver.ts
118
124
  /**
119
125
  * Build a strict, single-feature token resolver.
120
126
  *
@@ -132,7 +138,7 @@ function createFeatureResolver(config) {
132
138
  };
133
139
  }
134
140
  //#endregion
135
- //#region src/auth/token-args.ts
141
+ //#region src/auth/token-args/token-args.ts
136
142
  var TokenParseError = class extends Error {
137
143
  name = "TokenParseError";
138
144
  };
@@ -170,7 +176,7 @@ function parseTokenArgs(tokens) {
170
176
  return result;
171
177
  }
172
178
  //#endregion
173
- //#region src/plugin/capabilities.ts
179
+ //#region src/plugin/capabilities/capabilities.ts
174
180
  const CARDINALITY = {
175
181
  source: "single",
176
182
  ci: "single",
@@ -198,7 +204,7 @@ const CARDINALITY = {
198
204
  */
199
205
  const REQUIRED_CAPABILITIES = [];
200
206
  //#endregion
201
- //#region src/config/config.ts
207
+ //#region src/config/config/config.ts
202
208
  var ConfigError = class extends Error {
203
209
  name = "ConfigError";
204
210
  };
@@ -307,7 +313,64 @@ function resolveConfig(raw) {
307
313
  };
308
314
  }
309
315
  //#endregion
310
- //#region src/commands/contexts.ts
316
+ //#region src/config/load-config/load-config.ts
317
+ /**
318
+ * `holocron.config.{ts,js,mjs,cjs,json}` loader.
319
+ *
320
+ * File discovery + module loading (typed configs via `tsx`, no build
321
+ * step) is delegated to `@theholocron/datapad`. This module keeps the
322
+ * holocron-specific parts: filling `name` / `repo.name` defaults and
323
+ * running the config through `resolveConfig`.
324
+ *
325
+ * Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
326
+ * see ADR-0010 / issue #75 / #81.
327
+ */
328
+ /**
329
+ * Read + parse + resolve `holocron.config.*` starting from the given
330
+ * directory, walking up through ancestors to the nearest `.git` boundary
331
+ * when `cwd` itself has no match — every command is usable from any
332
+ * subdirectory of a repo, not only its exact root (see
333
+ * `@theholocron/datapad`'s own doc comment for the stop conditions).
334
+ * Throws {@link ConfigFileError} when nothing is found or a file cannot
335
+ * be loaded, or `ConfigError` when the config fails schema checks.
336
+ */
337
+ async function loadConfig(cwd) {
338
+ const found = await loadConfigFile({
339
+ cwd,
340
+ name: "holocron",
341
+ walkUp: true
342
+ });
343
+ if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
344
+ return {
345
+ resolved: resolveConfig(await deriveDefaults$1(found)),
346
+ filepath: found.filepath
347
+ };
348
+ }
349
+ async function deriveDefaults$1(found) {
350
+ const configDir = dirname(found.filepath);
351
+ const result = { ...found.config };
352
+ if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
353
+ if (result.repo && !result.repo.name) {
354
+ const remote = await readGitRemote(configDir);
355
+ if (remote?.host === "github.com") result.repo = {
356
+ ...result.repo,
357
+ name: remote.slug
358
+ };
359
+ }
360
+ const manifest = await loadTasksConfig(configDir, { holocron: found });
361
+ if (manifest.tasks) result.tasks = manifest.tasks;
362
+ if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
363
+ if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
364
+ if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
365
+ if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
366
+ return result;
367
+ }
368
+ async function readPackageJsonName(dir) {
369
+ const name = (await readPackageJson(dir))?.name;
370
+ return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
371
+ }
372
+ //#endregion
373
+ //#region src/commands/contexts/contexts.ts
311
374
  /**
312
375
  * Command → context. Keys are the full command path as it lands in
313
376
  * yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
@@ -368,183 +431,556 @@ function commandsInContext(context) {
368
431
  return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
369
432
  }
370
433
  //#endregion
371
- //#region src/ui/style.ts
372
- const style = {
373
- success: (msg) => `${chalk.green("✓")} ${msg}`,
374
- warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
375
- fail: (msg) => `${chalk.red("✗")} ${msg}`,
376
- step: (msg) => `${chalk.cyan("→")} ${msg}`,
377
- /** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
378
- skip: (msg) => chalk.dim(`· ${msg}`),
379
- hint: (msg) => chalk.dim(msg),
380
- dim: (msg) => chalk.dim(msg),
381
- header: (msg) => chalk.bold(msg)
382
- };
383
- //#endregion
384
- //#region src/logger.ts
385
- /**
386
- * Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
387
- * node`, …) — ADR-0012. These commands aren't "about" any repo, so their
388
- * logs must never depend on `$PWD` or which repo's `.envrc` happens to be
389
- * active; they always ship to holocron's own dataset instead of whatever
390
- * `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
391
- * `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
392
- * per-repo override.
393
- */
394
- const GLOBAL_AXIOM_DATASET = "holocron-global";
395
- /**
396
- * Resolve the explicit level to hand to `createLogger`, in priority order:
397
- *
398
- * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
399
- * 4. `HOLOCRON_LOG_LEVEL` env var
400
- * 5. `holocron.config` `log.level` (`configLevel`)
401
- *
402
- * Returns `undefined` when nothing applies — `createLogger` then defaults
403
- * to `"info"`. Resolving the full chain here (rather than passing
404
- * `configLevel` straight through) keeps config below the env var.
405
- */
406
- function resolveLogLevel(argv, configLevel) {
407
- if (argv.log) return argv.log;
408
- if (argv.verbose) return "debug";
409
- if (argv.quiet) return "error";
410
- return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
411
- }
434
+ //#region src/ui/non-interactive-error/non-interactive-error.ts
412
435
  /**
413
- * Resolve whether human-visible console output (pretty or NDJSON) should run
414
- * at all, independent of level — the `@theholocron/observability`
415
- * `consoleOutput` override.
416
- *
417
- * Local/interactive runs are quiet by default: any raw structured logger
418
- * output (colorized or not) stays off regardless of the resolved level,
419
- * unless the user explicitly asked to see it via `--log <level>`,
420
- * `--verbose`, or `--quiet`. CI is untouched either way — returning
421
- * `undefined` there preserves `createLogger`'s own `ci`/`tty`
422
- * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
423
- * unaffected by this flag.
436
+ * Raised when a prompt is needed but stdin isn't a TTY. A non-TTY run (CI, a
437
+ * script, a pipe) throws this instead of hanging on `@inquirer/prompts`, which
438
+ * otherwise throws its own `ExitPromptError` the moment stdin closes. `cli.ts`'s
439
+ * `USER_FACING_ERRORS` catch prints the message cleanly.
424
440
  */
425
- function resolveConsoleOutput(argv) {
426
- if (argv.log || argv.verbose || argv.quiet) return true;
427
- return isCI() ? void 0 : false;
441
+ var NonInteractiveError = class extends Error {
442
+ name = "NonInteractiveError";
443
+ };
444
+ //#endregion
445
+ //#region src/ui/json-view/json-view.ts
446
+ function isContainer(value) {
447
+ return typeof value === "object" && value !== null;
428
448
  }
429
- let root;
430
- let rootLevel;
431
- let rootCommand;
432
- let rootAxiomKey;
433
- /**
434
- * Resolve Axiom credentials for the CLI.
435
- *
436
- * A `global`-context command (ADR-0012) always resolves to
437
- * {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
438
- * `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
439
- * isn't "about" any repo, so the active directory's dataset is never the
440
- * right answer. Everything else keeps the existing chain: env vars win
441
- * (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
442
- * falling back to the CLI-only bridge that pairs the OS-keyring token
443
- * (`axiom.<org>` then bare `axiom`) with a dataset from
444
- * `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
445
- * Returns `undefined` unless both a token and a dataset are found.
446
- */
447
- function resolveCliAxiom(opts) {
448
- const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
449
- if (!isGlobal) {
450
- const fromEnv = resolveAxiomFromEnv();
451
- if (fromEnv) return fromEnv;
452
- }
453
- const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : env.get("HOLOCRON_AXIOM_DATASET") || env.get("AXIOM_DATASET") || opts.configAxiomDataset;
454
- if (!dataset) return void 0;
455
- const org = opts.org ?? env.get("HOLOCRON_ORG");
456
- const token = env.get("HOLOCRON_AXIOM_TOKEN") || env.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
457
- return token ? {
458
- dataset,
459
- token
460
- } : void 0;
449
+ function childrenOf(value) {
450
+ return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
461
451
  }
462
- /**
463
- * The process-wide root logger. Built once (from `cli.ts`'s middleware,
464
- * with the command name + flags + env). Rebuilt at most once more when a
465
- * command's handler supplies `holocron.config` context the flag/env-only
466
- * first pass could not have known — `log.level` (unless `--verbose` /
467
- * `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
468
- * for a keyring-backed Axiom transport. That rebuild generates a fresh
469
- * `runId`, which is harmless: nothing logs between the middleware and the
470
- * handler.
471
- */
472
- function buildCliLogger(argv, opts = {}) {
473
- const { command, configLevel } = opts;
474
- if (command) rootCommand = command;
475
- const level = resolveLogLevel(argv, configLevel);
476
- const consoleOutput = resolveConsoleOutput(argv);
477
- const axiom = resolveCliAxiom(opts);
478
- const axiomKey = axiom?.dataset;
479
- const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
480
- if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
481
- const built = createLogger({
482
- ...level ? { level } : {},
483
- ...axiom ? { axiom } : {},
484
- ...consoleOutput !== void 0 ? { consoleOutput } : {}
485
- });
486
- root = {
487
- logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
488
- runId: built.runId
489
- };
490
- rootLevel = level;
491
- rootAxiomKey = axiomKey;
492
- }
493
- return root;
452
+ function scalar(value) {
453
+ if (typeof value === "string") return chalk.green(JSON.stringify(value));
454
+ if (typeof value === "number") return chalk.yellow(String(value));
455
+ if (typeof value === "boolean") return chalk.magenta(String(value));
456
+ return chalk.dim(String(value === void 0 ? null : value));
494
457
  }
495
- /**
496
- * Lazily-memoized `Logger` for module-level call sites with no `argv` in
497
- * scope — notably yargs' own `.fail()` handler, which fires on a
498
- * pre-middleware validation failure (a missing required argument, an
499
- * unknown command) and therefore runs before `buildCliLogger` has ever
500
- * built the real root. Without an explicit `consoleOutput` here, that
501
- * fallback would use `createLogger`'s bare auto-detection (pretty on any
502
- * local TTY) and leak a structured block even though no flag asked for one
503
- * — so it resolves the same "quiet unless CI" default `buildCliLogger`
504
- * would, as if no flags were passed.
505
- */
506
- function getLogger() {
507
- return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
458
+ function summary(value) {
459
+ const n = childrenOf(value).length;
460
+ const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
461
+ return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
508
462
  }
509
463
  /**
510
- * Report a fatal, command-ending CLI error: always printed to the console
511
- * (regardless of `consoleOutput` suppression) via `style.fail`, and also
512
- * sent through the structured logger for Axiom/telemetry visibility.
513
- *
514
- * Use this when a handler is returning or exiting right here with nothing
515
- * else for the user to see — a resolved `--token` failure, a yargs
516
- * validation failure, a thrown domain error. Don't use it for a per-step
517
- * warning inside a multi-step orchestrator that already prints its own
518
- * `ok / fail / skip` summary — that summary is the user-facing signal;
519
- * the per-step reason belongs in the structured log alone.
464
+ * Paths of every container deeper than `depth` levels are left out, so those
465
+ * render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
520
466
  */
521
- function reportError(message, log = getLogger()) {
522
- console.error(style.fail(message));
523
- log.error(message);
524
- }
525
- /** The current root logger's correlation id, if a root has been built. */
526
- function getRunId() {
527
- return root?.runId;
467
+ function expandToDepth(value, depth) {
468
+ const out = /* @__PURE__ */ new Set();
469
+ const walk = (v, path, level) => {
470
+ if (!isContainer(v) || level >= depth) return;
471
+ out.add(path);
472
+ for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
473
+ };
474
+ walk(value, "", 0);
475
+ return out;
528
476
  }
529
- //#endregion
530
- //#region src/plugin/loader.ts
531
- /**
532
- * `PluginLoader` — loads provider plugins per the resolved config and
533
- * builds a typed capability registry the runtime can query.
534
- *
535
- * Flow:
536
- * 1. Walk `config.providers[*]` from `resolveConfig()`
537
- * 2. For each entry, dynamic-import the resolved package name
538
- * (`@theholocron/holocron-plugin-<provider>` by default)
539
- * 3. Call the package's exported `createPlugin(options)` to get a
540
- * plugin object whose `capabilities` map holds factories
541
- * 4. Invoke the matching capability factory and stash the impl in
542
- * the registry — single-cardinality entries hold one impl,
543
- * many-cardinality entries hold an array
544
- *
545
- * A package may instead export a capability config (see
546
- * `CapabilityConfigPackage` in config.ts). The loader detects the shape
547
- * at import time and re-resolves to the underlying plugin, merging the
477
+ /** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
478
+ function renderTree(value, { expanded }) {
479
+ const lines = [];
480
+ const walk = (v, path, level, label, trailing) => {
481
+ const indent = " ".repeat(level);
482
+ const prefix = `${indent}${label}`;
483
+ if (!isContainer(v)) {
484
+ lines.push({
485
+ path,
486
+ text: `${prefix}${scalar(v)}${trailing}`,
487
+ foldable: false,
488
+ expanded: false,
489
+ level
490
+ });
491
+ return;
492
+ }
493
+ const kids = childrenOf(v);
494
+ const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
495
+ if (kids.length === 0) {
496
+ lines.push({
497
+ path,
498
+ text: `${prefix}${open}${close}${trailing}`,
499
+ foldable: false,
500
+ expanded: false,
501
+ level
502
+ });
503
+ return;
504
+ }
505
+ if (!expanded.has(path)) {
506
+ lines.push({
507
+ path,
508
+ text: `${prefix}${summary(v)}${trailing}`,
509
+ foldable: true,
510
+ expanded: false,
511
+ level
512
+ });
513
+ return;
514
+ }
515
+ lines.push({
516
+ path,
517
+ text: `${prefix}${open}`,
518
+ foldable: true,
519
+ expanded: true,
520
+ level
521
+ });
522
+ kids.forEach(([k, child], i) => {
523
+ const comma = i < kids.length - 1 ? "," : "";
524
+ const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
525
+ walk(child, `${path}/${k}`, level + 1, childLabel, comma);
526
+ });
527
+ lines.push({
528
+ path: `${path}#close`,
529
+ text: `${indent}${close}${trailing}`,
530
+ foldable: false,
531
+ expanded: false,
532
+ level
533
+ });
534
+ };
535
+ walk(value, "", 0, "", "");
536
+ return lines;
537
+ }
538
+ /** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
539
+ function reduceKey(state, key, value, rows) {
540
+ const lines = renderTree(value, { expanded: state.expanded });
541
+ const cur = lines[state.cursor];
542
+ let { cursor } = state;
543
+ const expanded = new Set(state.expanded);
544
+ switch (key) {
545
+ case "up":
546
+ cursor = Math.max(0, cursor - 1);
547
+ break;
548
+ case "down":
549
+ cursor = Math.min(lines.length - 1, cursor + 1);
550
+ break;
551
+ case "right":
552
+ if (cur?.foldable) expanded.add(cur.path);
553
+ break;
554
+ case "left":
555
+ if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
556
+ else if (cur) {
557
+ const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
558
+ if (parent >= 0) cursor = parent;
559
+ }
560
+ break;
561
+ case "expandAll": return {
562
+ cursor,
563
+ top: state.top,
564
+ expanded: expandToDepth(value, Infinity)
565
+ };
566
+ case "collapseAll": return {
567
+ cursor: 0,
568
+ top: 0,
569
+ expanded: /* @__PURE__ */ new Set()
570
+ };
571
+ }
572
+ const count = renderTree(value, { expanded }).length;
573
+ cursor = Math.min(cursor, count - 1);
574
+ let top = state.top;
575
+ if (cursor < top) top = cursor;
576
+ if (cursor >= top + rows) top = cursor - rows + 1;
577
+ return {
578
+ cursor,
579
+ top: Math.max(0, top),
580
+ expanded
581
+ };
582
+ }
583
+ const KEYMAP = {
584
+ "\x1B[A": "up",
585
+ k: "up",
586
+ "\x1B[B": "down",
587
+ j: "down",
588
+ "\x1B[C": "right",
589
+ l: "right",
590
+ "\r": "right",
591
+ " ": "right",
592
+ "\x1B[D": "left",
593
+ h: "left",
594
+ E: "expandAll",
595
+ C: "collapseAll",
596
+ q: "quit",
597
+ "\x1B": "quit",
598
+ "": "quit"
599
+ };
600
+ function draw(out, value, state, rows) {
601
+ const lines = renderTree(value, { expanded: state.expanded });
602
+ const view = lines.slice(state.top, state.top + rows).map((l, i) => {
603
+ const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
604
+ return state.top + i === state.cursor ? chalk.inverse(row) : row;
605
+ });
606
+ const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
607
+ out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
608
+ }
609
+ async function runInteractive(value, depth) {
610
+ const { stdin, stdout } = process;
611
+ if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
612
+ const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
613
+ let state = {
614
+ cursor: 0,
615
+ top: 0,
616
+ expanded: expandToDepth(value, depth)
617
+ };
618
+ const wasRaw = stdin.isRaw;
619
+ stdin.setRawMode(true);
620
+ stdin.resume();
621
+ stdout.write("\x1B[?1049h\x1B[?25l");
622
+ const redraw = () => draw(stdout, value, state, rows());
623
+ stdout.on("resize", redraw);
624
+ try {
625
+ redraw();
626
+ await new Promise((resolve) => {
627
+ const onData = (buf) => {
628
+ const key = KEYMAP[buf.toString()];
629
+ if (key === "quit") {
630
+ stdin.off("data", onData);
631
+ resolve();
632
+ return;
633
+ }
634
+ if (key) {
635
+ state = reduceKey(state, key, value, rows());
636
+ redraw();
637
+ }
638
+ };
639
+ stdin.on("data", onData);
640
+ });
641
+ } finally {
642
+ stdout.off("resize", redraw);
643
+ stdout.write("\x1B[?25h\x1B[?1049l");
644
+ stdin.setRawMode(wasRaw);
645
+ stdin.pause();
646
+ }
647
+ }
648
+ /**
649
+ * Print `value` as JSON. Non-TTY stdout always gets plain, valid
650
+ * `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
651
+ * coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
652
+ */
653
+ async function viewJson(value, options = {}) {
654
+ const depth = options.depth ?? Infinity;
655
+ if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
656
+ if (!process.stdout.isTTY) {
657
+ console.log(JSON.stringify(value, null, 2));
658
+ return;
659
+ }
660
+ const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
661
+ console.log(lines.map((l) => l.text).join("\n"));
662
+ }
663
+ //#endregion
664
+ //#region src/ui/menu/menu.ts
665
+ /**
666
+ * Searchable autocomplete over one menu level's `items`. `select()` would work too
667
+ * at these list sizes, but `search()` degrades gracefully as the surface grows and
668
+ * costs nothing when it doesn't. Resolves to the picked item's `value`.
669
+ */
670
+ async function pickMenuItem(items, message = "What would you like to do?") {
671
+ return search({
672
+ message,
673
+ source: (term) => searchChoices(items, term)
674
+ });
675
+ }
676
+ /**
677
+ * `@inquirer/search`'s `source` callback, factored out as a plain function —
678
+ * unit-testable directly instead of only through a mocked `search()` call.
679
+ * Empty/undefined `term` (nothing typed yet) returns every item.
680
+ */
681
+ function searchChoices(items, term) {
682
+ const needle = term?.toLowerCase();
683
+ const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
684
+ const rank = (i) => {
685
+ if (!needle) return 0;
686
+ const name = i.name.toLowerCase();
687
+ return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
688
+ };
689
+ return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
690
+ name: i.name,
691
+ value: i.value,
692
+ description: i.description
693
+ }));
694
+ }
695
+ //#endregion
696
+ //#region src/ui/open-path/open-path.ts
697
+ /** Platform-appropriate command to open a path in the OS's file browser. */
698
+ function openCommand(path) {
699
+ if (process.platform === "darwin") return {
700
+ cmd: "open",
701
+ args: [path]
702
+ };
703
+ if (process.platform === "win32") return {
704
+ cmd: "cmd",
705
+ args: [
706
+ "/c",
707
+ "start",
708
+ "",
709
+ path
710
+ ]
711
+ };
712
+ return {
713
+ cmd: "xdg-open",
714
+ args: [path]
715
+ };
716
+ }
717
+ /**
718
+ * Open `path` in the OS's default file browser (Finder / Explorer /
719
+ * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
720
+ * it outlives this process, and never throws when the opener binary isn't
721
+ * on PATH (e.g. no `xdg-open` in a minimal container).
722
+ */
723
+ function openPath(path) {
724
+ const { cmd, args } = openCommand(path);
725
+ const child = spawn(cmd, args, {
726
+ stdio: "ignore",
727
+ detached: true
728
+ });
729
+ child.on("error", () => {});
730
+ child.unref();
731
+ }
732
+ //#endregion
733
+ //#region src/ui/progress/progress.ts
734
+ /**
735
+ * Runs `fn`, showing an ora spinner for its duration in TTY environments.
736
+ * In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
737
+ */
738
+ async function withSpinner(label, fn) {
739
+ if (!process.stdout.isTTY) return fn();
740
+ const spinner = ora(label).start();
741
+ try {
742
+ const result = await fn();
743
+ spinner.succeed();
744
+ return result;
745
+ } catch (err) {
746
+ spinner.fail();
747
+ throw err;
748
+ }
749
+ }
750
+ //#endregion
751
+ //#region src/ui/prompt-positionals/prompt-positionals.ts
752
+ /**
753
+ * For each of `entry.positionals` not already present in `argv`, prompts for
754
+ * it (`input()` or `select()`, per the positional's `type`) and returns the
755
+ * resolved values in positional order — ready to append to a spawned child's
756
+ * argv, or to use directly in the current handler (Layer 3).
757
+ *
758
+ * Throws {@link NonInteractiveError} the first time it would need to prompt
759
+ * on a non-TTY stdin, and again if a `select` positional's choices resolve
760
+ * empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
761
+ */
762
+ async function promptForPositionals(entry, argv) {
763
+ const out = [];
764
+ for (const positional of entry.positionals) {
765
+ const existing = argv[positional.key];
766
+ if (existing !== void 0 && existing !== "") {
767
+ out.push(String(existing));
768
+ continue;
769
+ }
770
+ if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
771
+ if (positional.type === "select") {
772
+ const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
773
+ if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
774
+ out.push(await select({
775
+ message: positional.message,
776
+ choices: choices.map((c) => ({ value: c }))
777
+ }));
778
+ } else out.push(await input({
779
+ message: positional.message,
780
+ ...positional.validate ? { validate: positional.validate } : {}
781
+ }));
782
+ }
783
+ return out;
784
+ }
785
+ //#endregion
786
+ //#region src/ui/style/style.ts
787
+ const style = {
788
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
789
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
790
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
791
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
792
+ /** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
793
+ skip: (msg) => chalk.dim(`· ${msg}`),
794
+ hint: (msg) => chalk.dim(msg),
795
+ dim: (msg) => chalk.dim(msg),
796
+ header: (msg) => chalk.bold(msg)
797
+ };
798
+ //#endregion
799
+ //#region src/ui/status-row/status-row.ts
800
+ /** Right-pads `s` to `width` with spaces — never truncates. */
801
+ function pad(s, width) {
802
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
803
+ }
804
+ /**
805
+ * One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
806
+ * then `columns` joined with a two-space gap (blank columns dropped, so a
807
+ * row that has nothing for its last column doesn't trail whitespace).
808
+ * Column padding (`pad`) is the caller's job — `doctor` pads capability and
809
+ * provider to a fixed width; `clone`'s repo names and paths don't, since
810
+ * they vary too widely for fixed-width alignment to read cleanly.
811
+ *
812
+ * Shared by `doctor` (capability / provider / message) and `clone` (repo /
813
+ * destination / reason) so every status table in the CLI reads the same way.
814
+ */
815
+ function statusRow(status, columns) {
816
+ const joined = columns.filter((c) => c.length > 0).join(" ");
817
+ return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
818
+ }
819
+ //#endregion
820
+ //#region src/observability/logger/logger.ts
821
+ /**
822
+ * Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
823
+ * node`, …) — ADR-0012. These commands aren't "about" any repo, so their
824
+ * logs must never depend on `$PWD` or which repo's `.envrc` happens to be
825
+ * active; they always ship to holocron's own dataset instead of whatever
826
+ * `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
827
+ * `resolve-telemetry.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
828
+ * per-repo override.
829
+ */
830
+ const GLOBAL_AXIOM_DATASET = "holocron-global";
831
+ /**
832
+ * Resolve the explicit level to hand to `createLogger`, in priority order:
833
+ *
834
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
835
+ * 4. `HOLOCRON_LOG_LEVEL` env var
836
+ * 5. `holocron.config` `log.level` (`configLevel`)
837
+ *
838
+ * Returns `undefined` when nothing applies — `createLogger` then defaults
839
+ * to `"info"`. Resolving the full chain here (rather than passing
840
+ * `configLevel` straight through) keeps config below the env var.
841
+ */
842
+ function resolveLogLevel(argv, configLevel) {
843
+ if (argv.log) return argv.log;
844
+ if (argv.verbose) return "debug";
845
+ if (argv.quiet) return "error";
846
+ return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
847
+ }
848
+ /**
849
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
850
+ * at all, independent of level — the `@theholocron/observability`
851
+ * `consoleOutput` override.
852
+ *
853
+ * Local/interactive runs are quiet by default: any raw structured logger
854
+ * output (colorized or not) stays off regardless of the resolved level,
855
+ * unless the user explicitly asked to see it via `--log <level>`,
856
+ * `--verbose`, or `--quiet`. CI is untouched either way — returning
857
+ * `undefined` there preserves `createLogger`'s own `ci`/`tty`
858
+ * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
859
+ * unaffected by this flag.
860
+ */
861
+ function resolveConsoleOutput(argv) {
862
+ if (argv.log || argv.verbose || argv.quiet) return true;
863
+ return isCI() ? void 0 : false;
864
+ }
865
+ let root;
866
+ let rootLevel;
867
+ let rootCommand;
868
+ let rootAxiomKey;
869
+ /**
870
+ * Resolve Axiom credentials for the CLI.
871
+ *
872
+ * A `global`-context command (ADR-0012) always resolves to
873
+ * {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
874
+ * `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
875
+ * isn't "about" any repo, so the active directory's dataset is never the
876
+ * right answer. Everything else keeps the existing chain: env vars win
877
+ * (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
878
+ * falling back to the CLI-only bridge that pairs the OS-keyring token
879
+ * (`axiom.<org>` then bare `axiom`) with a dataset from
880
+ * `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
881
+ * Returns `undefined` unless both a token and a dataset are found.
882
+ */
883
+ function resolveCliAxiom(opts) {
884
+ const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
885
+ if (!isGlobal) {
886
+ const fromEnv = resolveAxiomFromEnv();
887
+ if (fromEnv) return fromEnv;
888
+ }
889
+ const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : holocronEnv.get("AXIOM_DATASET") ?? opts.configAxiomDataset;
890
+ if (!dataset) return void 0;
891
+ const org = opts.org ?? env.get("HOLOCRON_ORG");
892
+ const token = holocronEnv.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
893
+ return token ? {
894
+ dataset,
895
+ token
896
+ } : void 0;
897
+ }
898
+ /**
899
+ * The process-wide root logger. Built once (from `cli.ts`'s middleware,
900
+ * with the command name + flags + env). Rebuilt at most once more when a
901
+ * command's handler supplies `holocron.config` context the flag/env-only
902
+ * first pass could not have known — `log.level` (unless `--verbose` /
903
+ * `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
904
+ * for a keyring-backed Axiom transport. That rebuild generates a fresh
905
+ * `runId`, which is harmless: nothing logs between the middleware and the
906
+ * handler.
907
+ */
908
+ function buildCliLogger(argv, opts = {}) {
909
+ const { command, configLevel } = opts;
910
+ if (command) rootCommand = command;
911
+ const level = resolveLogLevel(argv, configLevel);
912
+ const consoleOutput = resolveConsoleOutput(argv);
913
+ const axiom = resolveCliAxiom(opts);
914
+ const axiomKey = axiom?.dataset;
915
+ const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
916
+ if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
917
+ const built = createLogger({
918
+ ...level ? { level } : {},
919
+ ...axiom ? { axiom } : {},
920
+ ...consoleOutput !== void 0 ? { consoleOutput } : {}
921
+ });
922
+ root = {
923
+ logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
924
+ runId: built.runId
925
+ };
926
+ rootLevel = level;
927
+ rootAxiomKey = axiomKey;
928
+ }
929
+ return root;
930
+ }
931
+ /**
932
+ * Lazily-memoized `Logger` for module-level call sites with no `argv` in
933
+ * scope — notably yargs' own `.fail()` handler, which fires on a
934
+ * pre-middleware validation failure (a missing required argument, an
935
+ * unknown command) and therefore runs before `buildCliLogger` has ever
936
+ * built the real root. Without an explicit `consoleOutput` here, that
937
+ * fallback would use `createLogger`'s bare auto-detection (pretty on any
938
+ * local TTY) and leak a structured block even though no flag asked for one
939
+ * — so it resolves the same "quiet unless CI" default `buildCliLogger`
940
+ * would, as if no flags were passed.
941
+ */
942
+ function getLogger() {
943
+ return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
944
+ }
945
+ /**
946
+ * Report a fatal, command-ending CLI error: always printed to the console
947
+ * (regardless of `consoleOutput` suppression) via `style.fail`, and also
948
+ * sent through the structured logger for Axiom/telemetry visibility.
949
+ *
950
+ * Use this when a handler is returning or exiting right here with nothing
951
+ * else for the user to see — a resolved `--token` failure, a yargs
952
+ * validation failure, a thrown domain error. Don't use it for a per-step
953
+ * warning inside a multi-step orchestrator that already prints its own
954
+ * `ok / fail / skip` summary — that summary is the user-facing signal;
955
+ * the per-step reason belongs in the structured log alone.
956
+ */
957
+ function reportError(message, log = getLogger()) {
958
+ console.error(style.fail(message));
959
+ log.error(message);
960
+ }
961
+ /** The current root logger's correlation id, if a root has been built. */
962
+ function getRunId() {
963
+ return root?.runId;
964
+ }
965
+ //#endregion
966
+ //#region src/plugin/loader/loader.ts
967
+ /**
968
+ * `PluginLoader` — loads provider plugins per the resolved config and
969
+ * builds a typed capability registry the runtime can query.
970
+ *
971
+ * Flow:
972
+ * 1. Walk `config.providers[*]` from `resolveConfig()`
973
+ * 2. For each entry, dynamic-import the resolved package name
974
+ * (`@theholocron/holocron-plugin-<provider>` by default)
975
+ * 3. Call the package's exported `createPlugin(options)` to get a
976
+ * plugin object whose `capabilities` map holds factories
977
+ * 4. Invoke the matching capability factory and stash the impl in
978
+ * the registry — single-cardinality entries hold one impl,
979
+ * many-cardinality entries hold an array
980
+ *
981
+ * A package may instead export a capability config (see
982
+ * `CapabilityConfigPackage` in config.ts). The loader detects the shape
983
+ * at import time and re-resolves to the underlying plugin, merging the
548
984
  * preset options with any per-project overrides from the config file
549
985
  * (project options win, mirroring ESLint's `extends` precedence).
550
986
  *
@@ -692,7 +1128,111 @@ function isCapabilityConfigModule(mod) {
692
1128
  return typeof mod.default?.provider === "string";
693
1129
  }
694
1130
  //#endregion
695
- //#region src/plugin/workspace.ts
1131
+ //#region src/plugin/run-check/run-check.ts
1132
+ async function runCheckAgainstUrl(input, opts = {}) {
1133
+ const doFetch = opts.fetch ?? fetch;
1134
+ const url = resolveUrl(input.url, opts.url);
1135
+ const method = input.method ?? "GET";
1136
+ const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
1137
+ const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
1138
+ const start = Date.now();
1139
+ let response;
1140
+ let bodyText = "";
1141
+ try {
1142
+ response = await doFetch(url, {
1143
+ method,
1144
+ ...hasBody ? { body: input.body } : {},
1145
+ redirect: "manual",
1146
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
1147
+ });
1148
+ if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
1149
+ } catch (err) {
1150
+ return {
1151
+ passed: false,
1152
+ failures: [],
1153
+ requestError: requestErrorMessage(err)
1154
+ };
1155
+ }
1156
+ const responseTimeMs = Date.now() - start;
1157
+ const failures = [];
1158
+ if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
1159
+ for (const assertion of input.assertions ?? []) {
1160
+ const actual = extractActual(assertion, {
1161
+ response,
1162
+ bodyText,
1163
+ responseTimeMs
1164
+ });
1165
+ if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
1166
+ }
1167
+ return {
1168
+ passed: failures.length === 0,
1169
+ status: response.status,
1170
+ responseTimeMs,
1171
+ failures
1172
+ };
1173
+ }
1174
+ /** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
1175
+ function resolveUrl(originalUrl, overrideUrl) {
1176
+ if (!overrideUrl) return originalUrl;
1177
+ const original = new URL(originalUrl);
1178
+ const resolved = new URL(overrideUrl);
1179
+ resolved.pathname = original.pathname;
1180
+ resolved.search = original.search;
1181
+ return resolved.toString();
1182
+ }
1183
+ function requestErrorMessage(err) {
1184
+ if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
1185
+ return String(err);
1186
+ }
1187
+ /** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
1188
+ async function readBodyCapped(response, maxBytes) {
1189
+ if (!response.body) return "";
1190
+ const reader = response.body.getReader();
1191
+ const chunks = [];
1192
+ let total = 0;
1193
+ try {
1194
+ for (;;) {
1195
+ const { done, value } = await reader.read();
1196
+ if (done) break;
1197
+ const remaining = maxBytes - total;
1198
+ if (remaining <= 0) break;
1199
+ const stored = value.length > remaining ? value.slice(0, remaining) : value;
1200
+ chunks.push(stored);
1201
+ total += stored.length;
1202
+ }
1203
+ } finally {
1204
+ await reader.cancel().catch(() => {});
1205
+ }
1206
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
1207
+ }
1208
+ /** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
1209
+ function truncate(value, max = 200) {
1210
+ return value.length > max ? `${value.slice(0, max)}…` : value;
1211
+ }
1212
+ function extractActual(assertion, ctx) {
1213
+ switch (assertion.source) {
1214
+ case "status_code": return String(ctx.response.status);
1215
+ case "response_time": return String(ctx.responseTimeMs);
1216
+ case "body": return ctx.bodyText;
1217
+ case "header": {
1218
+ const name = assertion.property ?? "";
1219
+ return name ? ctx.response.headers.get(name) ?? "" : "";
1220
+ }
1221
+ }
1222
+ }
1223
+ function evaluate(comparison, actual, target) {
1224
+ const t = target ?? "";
1225
+ switch (comparison) {
1226
+ case "equals": return actual === t;
1227
+ case "not_equals": return actual !== t;
1228
+ case "contains": return actual.includes(t);
1229
+ case "not_contains": return !actual.includes(t);
1230
+ case "greater_than": return Number(actual) > Number(t);
1231
+ case "less_than": return Number(actual) < Number(t);
1232
+ }
1233
+ }
1234
+ //#endregion
1235
+ //#region src/plugin/workspace/workspace.ts
696
1236
  /**
697
1237
  * The `workspace`-context guard.
698
1238
  *
@@ -762,25 +1302,7 @@ function assertPluginsResolvable(loader, command) {
762
1302
  throw new WorkspaceContextError(command, [...new Set(failures.map((f) => f.packageName))]);
763
1303
  }
764
1304
  //#endregion
765
- //#region src/ui/progress.ts
766
- /**
767
- * Runs `fn`, showing an ora spinner for its duration in TTY environments.
768
- * In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
769
- */
770
- async function withSpinner(label, fn) {
771
- if (!process.stdout.isTTY) return fn();
772
- const spinner = ora(label).start();
773
- try {
774
- const result = await fn();
775
- spinner.succeed();
776
- return result;
777
- } catch (err) {
778
- spinner.fail();
779
- throw err;
780
- }
781
- }
782
- //#endregion
783
- //#region src/commands/auth.ts
1305
+ //#region src/commands/auth/auth.ts
784
1306
  /**
785
1307
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
786
1308
  * OS keyring.
@@ -813,10 +1335,8 @@ const defaultImporter = async (pkg) => {
813
1335
  * pulling FROM it would just re-store the same value.
814
1336
  */
815
1337
  function resolveAuthSetToken(input) {
816
- const e = makeEnv(input.env);
817
- const upper = input.provider.toUpperCase();
818
- const holocronKey = `HOLOCRON_${upper}_TOKEN`;
819
- return input.positional || e.get(holocronKey) || e.get(`${upper}_TOKEN`) || null;
1338
+ const e = withEnvPrefix(makeEnv(input.env), "HOLOCRON");
1339
+ return input.positional || e.get(`${input.provider.toUpperCase()}_TOKEN`) || null;
820
1340
  }
821
1341
  async function runAuthSet(input) {
822
1342
  const print = input.print ?? ((l) => console.log(l));
@@ -1063,7 +1583,7 @@ async function tryLoadHint(importer, packageName) {
1063
1583
  }
1064
1584
  }
1065
1585
  //#endregion
1066
- //#region src/commands/cleanup-preview.ts
1586
+ //#region src/commands/cleanup-preview/cleanup-preview.ts
1067
1587
  function prStateLabel(pr) {
1068
1588
  if (pr.merged) return style.success("merged");
1069
1589
  if (pr.state === "closed") return style.dim("closed (not merged)");
@@ -1203,28 +1723,7 @@ async function runCleanupPreview(input) {
1203
1723
  }
1204
1724
  }
1205
1725
  //#endregion
1206
- //#region src/ui/status-row.ts
1207
- /** Right-pads `s` to `width` with spaces — never truncates. */
1208
- function pad(s, width) {
1209
- return s.length >= width ? s : s + " ".repeat(width - s.length);
1210
- }
1211
- /**
1212
- * One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
1213
- * then `columns` joined with a two-space gap (blank columns dropped, so a
1214
- * row that has nothing for its last column doesn't trail whitespace).
1215
- * Column padding (`pad`) is the caller's job — `doctor` pads capability and
1216
- * provider to a fixed width; `clone`'s repo names and paths don't, since
1217
- * they vary too widely for fixed-width alignment to read cleanly.
1218
- *
1219
- * Shared by `doctor` (capability / provider / message) and `clone` (repo /
1220
- * destination / reason) so every status table in the CLI reads the same way.
1221
- */
1222
- function statusRow(status, columns) {
1223
- const joined = columns.filter((c) => c.length > 0).join(" ");
1224
- return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
1225
- }
1226
- //#endregion
1227
- //#region src/commands/clone.ts
1726
+ //#region src/commands/clone/clone.ts
1228
1727
  /**
1229
1728
  * Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
1230
1729
  * `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
@@ -1421,7 +1920,7 @@ async function runClone(input) {
1421
1920
  };
1422
1921
  }
1423
1922
  //#endregion
1424
- //#region src/commands/deploy.ts
1923
+ //#region src/commands/deploy/deploy.ts
1425
1924
  /**
1426
1925
  * `holocron deploy` — trigger a deployment via the configured
1427
1926
  * `deployment` capability.
@@ -1661,7 +2160,7 @@ async function runDeployFromFiles(input) {
1661
2160
  }
1662
2161
  }
1663
2162
  //#endregion
1664
- //#region src/commands/doctor.ts
2163
+ //#region src/commands/doctor/doctor.ts
1665
2164
  /**
1666
2165
  * `holocron doctor` — load the config, instantiate every capability,
1667
2166
  * print a per-capability readiness report.
@@ -1807,117 +2306,13 @@ function countResolvedLifecycle(report) {
1807
2306
  function mk(capability, provider, status, message) {
1808
2307
  return {
1809
2308
  capability,
1810
- provider,
1811
- status,
1812
- message
1813
- };
1814
- }
1815
- //#endregion
1816
- //#region src/plugin/run-check.ts
1817
- async function runCheckAgainstUrl(input, opts = {}) {
1818
- const doFetch = opts.fetch ?? fetch;
1819
- const url = resolveUrl(input.url, opts.url);
1820
- const method = input.method ?? "GET";
1821
- const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
1822
- const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
1823
- const start = Date.now();
1824
- let response;
1825
- let bodyText = "";
1826
- try {
1827
- response = await doFetch(url, {
1828
- method,
1829
- ...hasBody ? { body: input.body } : {},
1830
- redirect: "manual",
1831
- signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
1832
- });
1833
- if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
1834
- } catch (err) {
1835
- return {
1836
- passed: false,
1837
- failures: [],
1838
- requestError: requestErrorMessage(err)
1839
- };
1840
- }
1841
- const responseTimeMs = Date.now() - start;
1842
- const failures = [];
1843
- if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
1844
- for (const assertion of input.assertions ?? []) {
1845
- const actual = extractActual(assertion, {
1846
- response,
1847
- bodyText,
1848
- responseTimeMs
1849
- });
1850
- if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
1851
- }
1852
- return {
1853
- passed: failures.length === 0,
1854
- status: response.status,
1855
- responseTimeMs,
1856
- failures
1857
- };
1858
- }
1859
- /** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
1860
- function resolveUrl(originalUrl, overrideUrl) {
1861
- if (!overrideUrl) return originalUrl;
1862
- const original = new URL(originalUrl);
1863
- const resolved = new URL(overrideUrl);
1864
- resolved.pathname = original.pathname;
1865
- resolved.search = original.search;
1866
- return resolved.toString();
1867
- }
1868
- function requestErrorMessage(err) {
1869
- if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
1870
- return String(err);
1871
- }
1872
- /** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
1873
- async function readBodyCapped(response, maxBytes) {
1874
- if (!response.body) return "";
1875
- const reader = response.body.getReader();
1876
- const chunks = [];
1877
- let total = 0;
1878
- try {
1879
- for (;;) {
1880
- const { done, value } = await reader.read();
1881
- if (done) break;
1882
- const remaining = maxBytes - total;
1883
- if (remaining <= 0) break;
1884
- const stored = value.length > remaining ? value.slice(0, remaining) : value;
1885
- chunks.push(stored);
1886
- total += stored.length;
1887
- }
1888
- } finally {
1889
- await reader.cancel().catch(() => {});
1890
- }
1891
- return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
1892
- }
1893
- /** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
1894
- function truncate(value, max = 200) {
1895
- return value.length > max ? `${value.slice(0, max)}…` : value;
1896
- }
1897
- function extractActual(assertion, ctx) {
1898
- switch (assertion.source) {
1899
- case "status_code": return String(ctx.response.status);
1900
- case "response_time": return String(ctx.responseTimeMs);
1901
- case "body": return ctx.bodyText;
1902
- case "header": {
1903
- const name = assertion.property ?? "";
1904
- return name ? ctx.response.headers.get(name) ?? "" : "";
1905
- }
1906
- }
1907
- }
1908
- function evaluate(comparison, actual, target) {
1909
- const t = target ?? "";
1910
- switch (comparison) {
1911
- case "equals": return actual === t;
1912
- case "not_equals": return actual !== t;
1913
- case "contains": return actual.includes(t);
1914
- case "not_contains": return !actual.includes(t);
1915
- case "greater_than": return Number(actual) > Number(t);
1916
- case "less_than": return Number(actual) < Number(t);
1917
- }
2309
+ provider,
2310
+ status,
2311
+ message
2312
+ };
1918
2313
  }
1919
2314
  //#endregion
1920
- //#region src/commands/monitoring-check.ts
2315
+ //#region src/commands/monitoring-check/monitoring-check.ts
1921
2316
  async function runMonitoringCheck(input) {
1922
2317
  const print = input.print ?? ((line) => console.log(line));
1923
2318
  const logger = input.logger ?? getLogger();
@@ -1978,7 +2373,7 @@ async function runMonitoringCheck(input) {
1978
2373
  };
1979
2374
  }
1980
2375
  //#endregion
1981
- //#region src/commands/new.ts
2376
+ //#region src/commands/new/new.ts
1982
2377
  /**
1983
2378
  * `holocron new repo <type> <name>` — create a GitHub repo from a template and
1984
2379
  * bootstrap it by replacing all template-slug casing variants with the new
@@ -2372,7 +2767,7 @@ async function runNew(input) {
2372
2767
  };
2373
2768
  }
2374
2769
  //#endregion
2375
- //#region src/commands/npm-bump-versions.ts
2770
+ //#region src/commands/npm-bump-versions/npm-bump-versions.ts
2376
2771
  async function runNpmBumpVersions(input) {
2377
2772
  const print = input.print ?? ((line) => console.log(line));
2378
2773
  const logger = input.logger ?? getLogger();
@@ -2453,9 +2848,9 @@ async function runNpmBumpVersions(input) {
2453
2848
  };
2454
2849
  }
2455
2850
  //#endregion
2456
- //#region src/commands/plugin-create/template-inputs.ts
2851
+ //#region src/commands/plugin-create/template-inputs/template-inputs.ts
2457
2852
  /** Derive the standard defaults from a slug + vendor name. */
2458
- function deriveDefaults$1(input) {
2853
+ function deriveDefaults(input) {
2459
2854
  const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
2460
2855
  const capability = input.capability;
2461
2856
  return {
@@ -3394,7 +3789,7 @@ function runPluginCreate(input) {
3394
3789
  const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
3395
3790
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
3396
3791
  validateCapability(input.capability, print);
3397
- const derived = deriveDefaults$1({
3792
+ const derived = deriveDefaults({
3398
3793
  slug: input.slug,
3399
3794
  vendorName: input.vendorName,
3400
3795
  capability: input.capability
@@ -3550,224 +3945,142 @@ function printNextSteps$1(print, inputs) {
3550
3945
  print(" 7. Commit + push when capability is functionally complete.");
3551
3946
  }
3552
3947
  //#endregion
3553
- //#region src/commands/publish.ts
3948
+ //#region src/commands/publish/default-exec.ts
3949
+ const defaultExec$1 = async (cmd, args, opts) => {
3950
+ const result = spawnSync(cmd, args, {
3951
+ cwd: opts.cwd,
3952
+ encoding: "utf8",
3953
+ stdio: [
3954
+ "inherit",
3955
+ "pipe",
3956
+ "pipe"
3957
+ ]
3958
+ });
3959
+ return {
3960
+ exitCode: result.status ?? -1,
3961
+ stdout: result.stdout ?? "",
3962
+ stderr: result.stderr ?? ""
3963
+ };
3964
+ };
3965
+ //#endregion
3966
+ //#region src/commands/publish/default-login.ts
3554
3967
  /**
3555
- * `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
3556
- * that every new npm-published holocron monorepo hits.
3557
- *
3558
- * npm requires a package to exist before Trusted Publishing can be
3559
- * configured on it. So the first publish has to happen outside the OIDC
3560
- * flow — a browser-auth session (`npm login --auth-type=web`) or an
3561
- * ephemeral automation token. This command drives the login step itself
3562
- * (when `npm whoami` shows you're not authenticated), runs the publish
3563
- * step, and tells you exactly what to do next.
3564
- *
3565
- * Workflow:
3566
- *
3567
- * $ pnpm install --frozen-lockfile
3568
- * $ pnpm build
3569
- * $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
3570
- *
3571
- * `npm login --auth-type=web` runs automatically the first time — no
3572
- * separate manual step. `pnpm install` + `pnpm build` stay outside the
3573
- * command (no pnpm-inside-pnpm).
3574
- *
3575
- * `--initial` is required today — this command only implements the
3576
- * bootstrap publish. A non-initial `holocron package publish` (for a manual publish
3577
- * outside the semantic-release/OIDC steady state) isn't built yet; the flag
3578
- * exists so the surface doesn't need another rename when it is.
3579
- *
3580
- * If `NPM_TOKEN` is detected in env, the command prints a final
3581
- * "revoke this token at <url>" reminder — same pattern as `rando vc
3582
- * setup` for the ephemeral GH admin PAT.
3968
+ * Fully interactive — `npm login --auth-type=web` prints a URL and waits for
3969
+ * the browser flow to complete; the operator needs to see that live, so all
3970
+ * three stdio streams are inherited (unlike `defaultExec`, which pipes
3971
+ * stdout/stderr for capture).
3583
3972
  */
3973
+ const defaultLogin = async (cwd) => {
3974
+ return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
3975
+ cwd,
3976
+ stdio: "inherit"
3977
+ }).status ?? -1 };
3978
+ };
3979
+ //#endregion
3980
+ //#region src/commands/publish/has-packages-dir.ts
3981
+ function hasPackagesDir(cwd) {
3982
+ return existsSync(join(cwd, "packages"));
3983
+ }
3984
+ //#endregion
3985
+ //#region src/commands/publish/has-plugins-dir.ts
3986
+ /** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
3987
+ function hasPluginsDir(cwd) {
3988
+ return existsSync(join(cwd, "plugins"));
3989
+ }
3990
+ //#endregion
3991
+ //#region src/commands/publish/discover-manifests.ts
3584
3992
  /**
3585
- * `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
3586
- * Trusted Publisher (OIDC) config to a new workflow filename across every
3587
- * public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
3588
- * shell-script prototype this supersedes) for the full "why": npm ties
3589
- * Trusted Publisher to a workflow FILENAME per package, so renaming the
3590
- * release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
3591
- * config is updated to match.
3592
- *
3593
- * Requires npm@11.15.0+ (`npm trust`) — always shells out through
3594
- * `npx -y npm@latest` regardless of the locally-installed npm version.
3595
- *
3596
- * npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
3597
- * require a fresh interactive browser 2FA approval (an `EOTP` error with an
3598
- * `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
3599
- * with the bypass-2FA option are explicitly unsupported for trust commands).
3600
- * This command can't drive that browser step itself, so on the first `EOTP`
3601
- * it stops immediately, prints the `authUrl`, and returns — the operator
3602
- * approves it and re-runs; already-migrated packages are skipped on the next
3603
- * pass, so it's safe to resume from wherever it stopped.
3993
+ * Monorepo (`packages/` and/or `plugins/` present): every workspace under
3994
+ * either. Single package (neither dir present): the root `package.json`.
3995
+ * Callers keep the public ones.
3604
3996
  */
3605
- async function runSyncTrust(input) {
3606
- const print = input.print ?? ((line) => console.log(line));
3607
- const logger = input.logger ?? getLogger();
3608
- const cwd = input.cwd ?? process.cwd();
3609
- const repo = input.repo ?? "theholocron/holocron";
3610
- const dryRun = input.dryRun ?? false;
3611
- const exec = input.exec ?? defaultExec$1;
3612
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
3613
- const npm = "npx";
3614
- const npmArgs = ["-y", "npm@latest"];
3615
- print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
3616
- print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
3617
- print(` ${packageNames.length} public packages`);
3618
- print("");
3619
- logger.info({
3620
- oldFile: input.oldFile,
3621
- newFile: input.newFile,
3622
- packages: packageNames.length
3623
- }, "sync-trust: start");
3624
- if (packageNames.length === 0) {
3625
- const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
3626
- print(` ✗ ${message}`);
3627
- return {
3628
- status: "fail",
3629
- message,
3630
- packages: []
3631
- };
3632
- }
3633
- if (dryRun) {
3634
- for (const name of packageNames) print(` … would sync ${name}`);
3635
- return {
3636
- status: "dry-run",
3637
- message: "dry-run — no npm calls made",
3638
- packages: []
3639
- };
3640
- }
3641
- const results = [];
3642
- for (const name of packageNames) {
3643
- print(`=== ${name} ===`);
3644
- const parsed = parseJson((await exec(npm, [
3645
- ...npmArgs,
3646
- "trust",
3647
- "list",
3648
- name,
3649
- "--json"
3650
- ], { cwd })).stdout);
3651
- if (isEotp(parsed)) {
3652
- const authUrl = parsed.error?.authUrl ?? "";
3653
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3654
- print(" then re-run; already-migrated packages are skipped.");
3655
- results.push({
3656
- name,
3657
- status: "needs-auth",
3658
- message: authUrl
3659
- });
3660
- logger.warn({
3661
- package: name,
3662
- authUrl
3663
- }, "sync-trust: needs auth, stopping");
3664
- return {
3665
- status: "fail",
3666
- message: "npm 2FA grant needed — see printed authUrl",
3667
- packages: results
3668
- };
3669
- }
3670
- const entry = parsed;
3671
- if (entry.file === input.newFile) {
3672
- print(` already on ${input.newFile}, skipping`);
3673
- results.push({
3674
- name,
3675
- status: "skipped"
3676
- });
3677
- continue;
3678
- }
3679
- if (entry.id) {
3680
- print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
3681
- const revoke = await exec(npm, [
3682
- ...npmArgs,
3683
- "trust",
3684
- "revoke",
3685
- name,
3686
- "--id",
3687
- entry.id,
3688
- "-y"
3689
- ], { cwd });
3690
- if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
3691
- const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
3692
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3693
- results.push({
3694
- name,
3695
- status: "needs-auth",
3696
- message: authUrl
3697
- });
3698
- return {
3699
- status: "fail",
3700
- message: "npm 2FA grant needed — see printed authUrl",
3701
- packages: results
3702
- };
3703
- }
3704
- } else print(" no existing trust config found");
3705
- print(` creating trust: ${input.newFile}`);
3706
- const create = await exec(npm, [
3707
- ...npmArgs,
3708
- "trust",
3709
- "github",
3710
- name,
3711
- "--repo",
3712
- repo,
3713
- "--file",
3714
- input.newFile,
3715
- "--allow-publish",
3716
- "--allow-stage-publish",
3717
- "-y"
3718
- ], { cwd });
3719
- if (create.exitCode === 0) {
3720
- print(" ✓ ok");
3721
- results.push({
3722
- name,
3723
- status: "ok"
3724
- });
3725
- } else {
3726
- const createParsed = parseJson(create.stdout);
3727
- if (isEotp(createParsed)) {
3728
- const authUrl = createParsed.error?.authUrl ?? "";
3729
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3730
- results.push({
3731
- name,
3732
- status: "needs-auth",
3733
- message: authUrl
3734
- });
3735
- return {
3736
- status: "fail",
3737
- message: "npm 2FA grant needed — see printed authUrl",
3738
- packages: results
3739
- };
3740
- }
3741
- const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
3742
- print(` ✗ FAILED: ${message}`);
3743
- results.push({
3744
- name,
3745
- status: "fail",
3746
- message
3747
- });
3748
- }
3997
+ function discoverManifests(cwd) {
3998
+ if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
3999
+ const manifest = readPackageJsonSync(cwd);
4000
+ return manifest ? [{
4001
+ manifest,
4002
+ dir: "."
4003
+ }] : [];
3749
4004
  }
3750
- const failed = results.filter((r) => r.status === "fail");
3751
- logger.info({
3752
- ok: results.filter((r) => r.status === "ok").length,
3753
- failed: failed.length
3754
- }, "sync-trust: done");
3755
- return {
3756
- status: failed.length > 0 ? "fail" : "ok",
3757
- ...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
3758
- packages: results
3759
- };
4005
+ return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
4006
+ manifest,
4007
+ dir: `./${relativeDir}`
4008
+ }));
3760
4009
  }
3761
- function parseJson(raw) {
3762
- try {
3763
- return JSON.parse(raw);
3764
- } catch {
3765
- return;
4010
+ //#endregion
4011
+ //#region src/commands/publish/discover-public-packages.ts
4012
+ /** The names of `discoverPublicPackageEntries`' packages, without requiring a version. */
4013
+ function discoverPublicPackages(cwd) {
4014
+ return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
4015
+ }
4016
+ //#endregion
4017
+ //#region src/commands/publish/print-next-steps.ts
4018
+ function printNextSteps(print, env, packageNames, repoName) {
4019
+ print("");
4020
+ print(" → next: configure Trusted Publisher for each package on npm:");
4021
+ for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
4022
+ print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
4023
+ if (env.get("NPM_TOKEN")) {
4024
+ print("");
4025
+ print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
4026
+ print(" https://www.npmjs.com/settings/~/tokens");
3766
4027
  }
3767
4028
  }
3768
- function isEotp(parsed) {
3769
- return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
4029
+ //#endregion
4030
+ //#region src/commands/publish/resolve-repo-name.ts
4031
+ async function resolveRepoName(cwd, exec) {
4032
+ return (await readGitRemote(cwd, { exec: async (args, options) => {
4033
+ const result = await exec("git", args, options);
4034
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4035
+ return result.stdout;
4036
+ } }))?.repo ?? "unknown";
4037
+ }
4038
+ //#endregion
4039
+ //#region src/commands/publish/workspace-filter-args.ts
4040
+ /**
4041
+ * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4042
+ * roots exist. Empty (single-package repo, no `-r`) when neither does.
4043
+ * Shared by every `pnpm publish` invocation below so a repo with only
4044
+ * `plugins/*` (or both) is covered the same as `packages/*`-only.
4045
+ */
4046
+ function workspaceFilterArgs(cwd) {
4047
+ const filters = [];
4048
+ if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4049
+ if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4050
+ return filters.length > 0 ? ["-r", ...filters] : [];
3770
4051
  }
4052
+ //#endregion
4053
+ //#region src/commands/publish/run-publish/run-publish.ts
4054
+ /**
4055
+ * `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
4056
+ * that every new npm-published holocron monorepo hits.
4057
+ *
4058
+ * npm requires a package to exist before Trusted Publishing can be
4059
+ * configured on it. So the first publish has to happen outside the OIDC
4060
+ * flow — a browser-auth session (`npm login --auth-type=web`) or an
4061
+ * ephemeral automation token. This command drives the login step itself
4062
+ * (when `npm whoami` shows you're not authenticated), runs the publish
4063
+ * step, and tells you exactly what to do next.
4064
+ *
4065
+ * Workflow:
4066
+ *
4067
+ * $ pnpm install --frozen-lockfile
4068
+ * $ pnpm build
4069
+ * $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
4070
+ *
4071
+ * `npm login --auth-type=web` runs automatically the first time — no
4072
+ * separate manual step. `pnpm install` + `pnpm build` stay outside the
4073
+ * command (no pnpm-inside-pnpm).
4074
+ *
4075
+ * `--initial` is required today — this command only implements the
4076
+ * bootstrap publish. A non-initial `holocron package publish` (for a manual publish
4077
+ * outside the semantic-release/OIDC steady state) isn't built yet; the flag
4078
+ * exists so the surface doesn't need another rename when it is.
4079
+ *
4080
+ * If `NPM_TOKEN` is detected in env, the command prints a final
4081
+ * "revoke this token at <url>" reminder — same pattern as `rando vc
4082
+ * setup` for the ephemeral GH admin PAT.
4083
+ */
3771
4084
  async function runPublish(input = {}) {
3772
4085
  const print = input.print ?? ((line) => console.log(line));
3773
4086
  const logger = input.logger ?? getLogger();
@@ -3888,6 +4201,23 @@ async function runPublish(input = {}) {
3888
4201
  packageNames
3889
4202
  };
3890
4203
  }
4204
+ //#endregion
4205
+ //#region src/commands/publish/discover-public-package-entries.ts
4206
+ /**
4207
+ * Every public package (`!private && name`) with its version and a
4208
+ * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4209
+ * mode needs both to check `npm view <pkg>@<version>` and publish one
4210
+ * workspace at a time.
4211
+ */
4212
+ function discoverPublicPackageEntries(cwd) {
4213
+ return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4214
+ name: manifest.name,
4215
+ version: manifest.version,
4216
+ dir
4217
+ }] : []);
4218
+ }
4219
+ //#endregion
4220
+ //#region src/commands/publish/run-steady-publish/run-steady-publish.ts
3891
4221
  /**
3892
4222
  * `holocron package publish` (no `--initial`) — the steady-state publish
3893
4223
  * `exec.publishCmd` in `release.config.ts` should call, replacing each
@@ -4031,129 +4361,222 @@ async function runSteadyPublish(input = {}) {
4031
4361
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
4032
4362
  print(` pnpm exec holocron package publish --otp <6-digit-code>`);
4033
4363
  }
4034
- logger.warn({
4035
- tag,
4036
- reason: message
4037
- }, "publish: done");
4038
- return {
4039
- status: "fail",
4040
- message,
4041
- packageNames
4042
- };
4364
+ logger.warn({
4365
+ tag,
4366
+ reason: message
4367
+ }, "publish: done");
4368
+ return {
4369
+ status: "fail",
4370
+ message,
4371
+ packageNames
4372
+ };
4373
+ }
4374
+ print(" ✓ publish complete");
4375
+ logger.info({
4376
+ tag,
4377
+ status: "ok",
4378
+ packages: entries.length
4379
+ }, "publish: done");
4380
+ return {
4381
+ status: "ok",
4382
+ packageNames
4383
+ };
4384
+ }
4385
+ //#endregion
4386
+ //#region src/commands/publish/is-eotp.ts
4387
+ function isEotp(parsed) {
4388
+ return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
4389
+ }
4390
+ //#endregion
4391
+ //#region src/commands/publish/parse-json.ts
4392
+ function parseJson(raw) {
4393
+ try {
4394
+ return JSON.parse(raw);
4395
+ } catch {
4396
+ return;
4397
+ }
4398
+ }
4399
+ //#endregion
4400
+ //#region src/commands/publish/run-sync-trust/run-sync-trust.ts
4401
+ /**
4402
+ * `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
4403
+ * Trusted Publisher (OIDC) config to a new workflow filename across every
4404
+ * public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
4405
+ * shell-script prototype this supersedes) for the full "why": npm ties
4406
+ * Trusted Publisher to a workflow FILENAME per package, so renaming the
4407
+ * release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
4408
+ * config is updated to match.
4409
+ *
4410
+ * Requires npm@11.15.0+ (`npm trust`) — always shells out through
4411
+ * `npx -y npm@latest` regardless of the locally-installed npm version.
4412
+ *
4413
+ * npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
4414
+ * require a fresh interactive browser 2FA approval (an `EOTP` error with an
4415
+ * `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
4416
+ * with the bypass-2FA option are explicitly unsupported for trust commands).
4417
+ * This command can't drive that browser step itself, so on the first `EOTP`
4418
+ * it stops immediately, prints the `authUrl`, and returns — the operator
4419
+ * approves it and re-runs; already-migrated packages are skipped on the next
4420
+ * pass, so it's safe to resume from wherever it stopped.
4421
+ */
4422
+ async function runSyncTrust(input) {
4423
+ const print = input.print ?? ((line) => console.log(line));
4424
+ const logger = input.logger ?? getLogger();
4425
+ const cwd = input.cwd ?? process.cwd();
4426
+ const repo = input.repo ?? "theholocron/holocron";
4427
+ const dryRun = input.dryRun ?? false;
4428
+ const exec = input.exec ?? defaultExec$1;
4429
+ const packageNames = input.packages ?? discoverPublicPackages(cwd);
4430
+ const npm = "npx";
4431
+ const npmArgs = ["-y", "npm@latest"];
4432
+ print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
4433
+ print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
4434
+ print(` ${packageNames.length} public packages`);
4435
+ print("");
4436
+ logger.info({
4437
+ oldFile: input.oldFile,
4438
+ newFile: input.newFile,
4439
+ packages: packageNames.length
4440
+ }, "sync-trust: start");
4441
+ if (packageNames.length === 0) {
4442
+ const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
4443
+ print(` ✗ ${message}`);
4444
+ return {
4445
+ status: "fail",
4446
+ message,
4447
+ packages: []
4448
+ };
4449
+ }
4450
+ if (dryRun) {
4451
+ for (const name of packageNames) print(` … would sync ${name}`);
4452
+ return {
4453
+ status: "dry-run",
4454
+ message: "dry-run — no npm calls made",
4455
+ packages: []
4456
+ };
4457
+ }
4458
+ const results = [];
4459
+ for (const name of packageNames) {
4460
+ print(`=== ${name} ===`);
4461
+ const parsed = parseJson((await exec(npm, [
4462
+ ...npmArgs,
4463
+ "trust",
4464
+ "list",
4465
+ name,
4466
+ "--json"
4467
+ ], { cwd })).stdout);
4468
+ if (isEotp(parsed)) {
4469
+ const authUrl = parsed.error?.authUrl ?? "";
4470
+ print(` needs fresh 2FA — open and approve: ${authUrl}`);
4471
+ print(" then re-run; already-migrated packages are skipped.");
4472
+ results.push({
4473
+ name,
4474
+ status: "needs-auth",
4475
+ message: authUrl
4476
+ });
4477
+ logger.warn({
4478
+ package: name,
4479
+ authUrl
4480
+ }, "sync-trust: needs auth, stopping");
4481
+ return {
4482
+ status: "fail",
4483
+ message: "npm 2FA grant needed — see printed authUrl",
4484
+ packages: results
4485
+ };
4486
+ }
4487
+ const entry = parsed;
4488
+ if (entry.file === input.newFile) {
4489
+ print(` already on ${input.newFile}, skipping`);
4490
+ results.push({
4491
+ name,
4492
+ status: "skipped"
4493
+ });
4494
+ continue;
4495
+ }
4496
+ if (entry.id) {
4497
+ print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
4498
+ const revoke = await exec(npm, [
4499
+ ...npmArgs,
4500
+ "trust",
4501
+ "revoke",
4502
+ name,
4503
+ "--id",
4504
+ entry.id,
4505
+ "-y"
4506
+ ], { cwd });
4507
+ if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
4508
+ const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
4509
+ print(` needs fresh 2FA — open and approve: ${authUrl}`);
4510
+ results.push({
4511
+ name,
4512
+ status: "needs-auth",
4513
+ message: authUrl
4514
+ });
4515
+ return {
4516
+ status: "fail",
4517
+ message: "npm 2FA grant needed — see printed authUrl",
4518
+ packages: results
4519
+ };
4520
+ }
4521
+ } else print(" no existing trust config found");
4522
+ print(` creating trust: ${input.newFile}`);
4523
+ const create = await exec(npm, [
4524
+ ...npmArgs,
4525
+ "trust",
4526
+ "github",
4527
+ name,
4528
+ "--repo",
4529
+ repo,
4530
+ "--file",
4531
+ input.newFile,
4532
+ "--allow-publish",
4533
+ "--allow-stage-publish",
4534
+ "-y"
4535
+ ], { cwd });
4536
+ if (create.exitCode === 0) {
4537
+ print(" ✓ ok");
4538
+ results.push({
4539
+ name,
4540
+ status: "ok"
4541
+ });
4542
+ } else {
4543
+ const createParsed = parseJson(create.stdout);
4544
+ if (isEotp(createParsed)) {
4545
+ const authUrl = createParsed.error?.authUrl ?? "";
4546
+ print(` needs fresh 2FA — open and approve: ${authUrl}`);
4547
+ results.push({
4548
+ name,
4549
+ status: "needs-auth",
4550
+ message: authUrl
4551
+ });
4552
+ return {
4553
+ status: "fail",
4554
+ message: "npm 2FA grant needed — see printed authUrl",
4555
+ packages: results
4556
+ };
4557
+ }
4558
+ const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
4559
+ print(` ✗ FAILED: ${message}`);
4560
+ results.push({
4561
+ name,
4562
+ status: "fail",
4563
+ message
4564
+ });
4565
+ }
4043
4566
  }
4044
- print(" ✓ publish complete");
4567
+ const failed = results.filter((r) => r.status === "fail");
4045
4568
  logger.info({
4046
- tag,
4047
- status: "ok",
4048
- packages: entries.length
4049
- }, "publish: done");
4569
+ ok: results.filter((r) => r.status === "ok").length,
4570
+ failed: failed.length
4571
+ }, "sync-trust: done");
4050
4572
  return {
4051
- status: "ok",
4052
- packageNames
4573
+ status: failed.length > 0 ? "fail" : "ok",
4574
+ ...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
4575
+ packages: results
4053
4576
  };
4054
4577
  }
4055
- function hasPackagesDir(cwd) {
4056
- return existsSync(join(cwd, "packages"));
4057
- }
4058
- /** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
4059
- function hasPluginsDir(cwd) {
4060
- return existsSync(join(cwd, "plugins"));
4061
- }
4062
- /**
4063
- * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4064
- * roots exist. Empty (single-package repo, no `-r`) when neither does.
4065
- * Shared by every `pnpm publish` invocation below so a repo with only
4066
- * `plugins/*` (or both) is covered the same as `packages/*`-only.
4067
- */
4068
- function workspaceFilterArgs(cwd) {
4069
- const filters = [];
4070
- if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4071
- if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4072
- return filters.length > 0 ? ["-r", ...filters] : [];
4073
- }
4074
- /**
4075
- * Monorepo (`packages/` and/or `plugins/` present): every workspace under
4076
- * either. Single package (neither dir present): the root `package.json`.
4077
- * Callers keep the public ones.
4078
- */
4079
- function discoverManifests(cwd) {
4080
- if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
4081
- const manifest = readPackageJsonSync(cwd);
4082
- return manifest ? [{
4083
- manifest,
4084
- dir: "."
4085
- }] : [];
4086
- }
4087
- return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
4088
- manifest,
4089
- dir: `./${relativeDir}`
4090
- }));
4091
- }
4092
- /**
4093
- * Every public package (`!private && name`) with its version and a
4094
- * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4095
- * mode needs both to check `npm view <pkg>@<version>` and publish one
4096
- * workspace at a time.
4097
- */
4098
- function discoverPublicPackageEntries(cwd) {
4099
- return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4100
- name: manifest.name,
4101
- version: manifest.version,
4102
- dir
4103
- }] : []);
4104
- }
4105
- /** The names of {@link discoverPublicPackageEntries}' packages, without requiring a version. */
4106
- function discoverPublicPackages(cwd) {
4107
- return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
4108
- }
4109
- async function resolveRepoName(cwd, exec) {
4110
- return (await readGitRemote(cwd, { exec: async (args, options) => {
4111
- const result = await exec("git", args, options);
4112
- if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4113
- return result.stdout;
4114
- } }))?.repo ?? "unknown";
4115
- }
4116
- function printNextSteps(print, env, packageNames, repoName) {
4117
- print("");
4118
- print(" → next: configure Trusted Publisher for each package on npm:");
4119
- for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
4120
- print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
4121
- if (env.get("NPM_TOKEN")) {
4122
- print("");
4123
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
4124
- print(" https://www.npmjs.com/settings/~/tokens");
4125
- }
4126
- }
4127
- const defaultExec$1 = async (cmd, args, opts) => {
4128
- const result = spawnSync(cmd, args, {
4129
- cwd: opts.cwd,
4130
- encoding: "utf8",
4131
- stdio: [
4132
- "inherit",
4133
- "pipe",
4134
- "pipe"
4135
- ]
4136
- });
4137
- return {
4138
- exitCode: result.status ?? -1,
4139
- stdout: result.stdout ?? "",
4140
- stderr: result.stderr ?? ""
4141
- };
4142
- };
4143
- /**
4144
- * Fully interactive — `npm login --auth-type=web` prints a URL and waits for
4145
- * the browser flow to complete; the operator needs to see that live, so all
4146
- * three stdio streams are inherited (unlike `defaultExec`, which pipes
4147
- * stdout/stderr for capture).
4148
- */
4149
- const defaultLogin = async (cwd) => {
4150
- return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
4151
- cwd,
4152
- stdio: "inherit"
4153
- }).status ?? -1 };
4154
- };
4155
4578
  //#endregion
4156
- //#region src/commands/secret-set.ts
4579
+ //#region src/commands/secret-set/secret-set.ts
4157
4580
  /**
4158
4581
  * `holocron secrets set` — one-shot single-secret push.
4159
4582
  *
@@ -4245,7 +4668,7 @@ function describeScope(scope) {
4245
4668
  return `scope=organization:${scope.name}`;
4246
4669
  }
4247
4670
  //#endregion
4248
- //#region src/commands/secrets-sync.ts
4671
+ //#region src/commands/secrets-sync/secrets-sync.ts
4249
4672
  /**
4250
4673
  * `holocron secrets sync` — vault → destinations.
4251
4674
  *
@@ -4410,7 +4833,7 @@ function vaultProviderName(loader) {
4410
4833
  return loader.get("vault").providerName;
4411
4834
  }
4412
4835
  //#endregion
4413
- //#region src/commands/setup/derived-properties.ts
4836
+ //#region src/commands/setup/derived-properties/derived-properties.ts
4414
4837
  /**
4415
4838
  * Repo archetype, in priority order:
4416
4839
  * 1. `*-template` repo name → `template` (independent of package shape).
@@ -4604,221 +5027,9 @@ const STALE_LABELS = [
4604
5027
  "released on @alpha"
4605
5028
  ];
4606
5029
  //#endregion
4607
- //#region src/templates/config.yml
4608
- var config_default = "# Configuration for sentiment-bot - https://github.com/behaviorbot/sentiment-bot\n\n# *Required* toxicity threshold between 0 and .99 with the higher numbers being the most toxic\n# Anything higher than this threshold will be marked as toxic and commented on\nsentimentBotToxicityThreshold: .7\n\n# *Required* Comment to reply with\nsentimentBotReplyComment: >\n Please be sure to review the [Code of Conduct](https://docs.theholocron.dev/reference/code-of-conduct/) and be respectful of other users.\n";
4609
- //#endregion
4610
- //#region src/utils/create-header.ts
4611
- function createHeader(options) {
4612
- const { source, tool = "holocron repo setup", forPrimary = false } = options;
4613
- const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
4614
- return {
4615
- workflowHeader(format = "yaml") {
4616
- if (format === "cjs") return [
4617
- `/* ${doNotEdit}`,
4618
- ` * Source: theholocron/holocron · ${source}`,
4619
- ` * Tool: ${tool}`,
4620
- ` * Changes: edit source in theholocron/holocron`,
4621
- ` */`,
4622
- ``
4623
- ].join("\n");
4624
- const yamlLines = [
4625
- `# ${doNotEdit}`,
4626
- `# Source: theholocron/holocron · ${source}`,
4627
- `# Tool: ${tool}`,
4628
- `# Changes: edit source in theholocron/holocron`,
4629
- ``
4630
- ].join("\n");
4631
- if (format === "shebang") return `#!/bin/sh\n\n${yamlLines}`;
4632
- return yamlLines;
4633
- },
4634
- scaffoldHeader() {
4635
- return [
4636
- `# Scaffolded by holocron repo setup — edit this file freely.`,
4637
- `# Source: theholocron/holocron · ${source}`,
4638
- ``
4639
- ].join("\n");
4640
- }
4641
- };
4642
- }
4643
- //#endregion
4644
- //#region src/templates/configs/alexjs/alexignore
4645
- var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
4646
- //#endregion
4647
- //#region src/templates/configs/alexjs/alexrc.json
4648
- var alexrc_default = { allow: [
4649
- "dead",
4650
- "execute",
4651
- "execution",
4652
- "executes",
4653
- "failure",
4654
- "failures",
4655
- "hook",
4656
- "hooks",
4657
- "husky",
4658
- "period"
4659
- ] };
4660
- //#endregion
4661
- //#region src/templates/configs/alexjs/create-config.ts
4662
- const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4663
- function createRcConfig() {
4664
- return stringifyJson(alexrc_default);
4665
- }
4666
- function createIgnoreConfig$1() {
4667
- return `${workflowHeader$9()}${alexignore_default}`;
4668
- }
4669
- //#endregion
4670
- //#region src/templates/configs/commit-msg/commit-msg
4671
- var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
4672
- //#endregion
4673
- //#region src/templates/configs/commit-msg/create-config.ts
4674
- const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4675
- /**
4676
- * `.husky/commit-msg` — lints the commit message via `holocron run lint
4677
- * commit-msg` (holocron#789), the real programmatic replacement for
4678
- * shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
4679
- * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4680
- *
4681
- * @param holocronScript - the command that invokes the CLI. Defaults to
4682
- * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4683
- */
4684
- function createConfig$7(holocronScript = "pnpm exec holocron") {
4685
- return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4686
- }
4687
- //#endregion
4688
- //#region src/templates/configs/devmoji/create-config.ts
4689
- const { workflowHeader: workflowHeader$7 } = createHeader({ source: "packages/cli/src/templates/configs/devmoji/create-config.ts" });
4690
- function createConfig$6() {
4691
- return [
4692
- workflowHeader$7("cjs"),
4693
- `/* eslint-disable */`,
4694
- `const { defineConfig } = require("@theholocron/devmoji-config");`,
4695
- `module.exports = defineConfig();`,
4696
- ``
4697
- ].join("\n");
4698
- }
4699
- //#endregion
4700
- //#region src/templates/configs/editorconfig/editorconfig
4701
- 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";
4702
- //#endregion
4703
- //#region src/templates/configs/editorconfig/create-config.ts
4704
- const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
4705
- function createConfig$5() {
4706
- return `${workflowHeader$6()}${editorconfig_default}`;
4707
- }
4708
- //#endregion
4709
- //#region src/templates/configs/editorconfig-checker/editorconfig-checker.json
4710
- var editorconfig_checker_default = {
4711
- Verbose: false,
4712
- Format: "",
4713
- Debug: false,
4714
- IgnoreDefaults: false,
4715
- SpacesAfterTabs: false,
4716
- NoColor: false,
4717
- Exclude: [
4718
- "(^|.+/)LICENSE$",
4719
- "^public/.*",
4720
- "\\.md$",
4721
- "\\.mdx$"
4722
- ],
4723
- AllowedContentTypes: [],
4724
- PassedFiles: [],
4725
- Disable: {
4726
- "EndOfLine": false,
4727
- "Indentation": false,
4728
- "InsertFinalNewline": false,
4729
- "TrimTrailingWhitespace": false,
4730
- "IndentSize": false,
4731
- "MaxLineLength": false
4732
- }
4733
- };
4734
- //#endregion
4735
- //#region src/templates/configs/editorconfig-checker/create-config.ts
4736
- /**
4737
- * The generated `.editorconfig-checker.json`.
4738
- *
4739
- * Deliberately carries no `"Version"` key: editorconfig-checker treats it as a
4740
- * hard gate and exits non-zero the moment the running binary's version differs
4741
- * — which it does the instant a contributor's local install (Homebrew tracks
4742
- * latest) drifts from the version super-linter bundles in CI, breaking
4743
- * `holocron run lint` / the pre-push hook for everyone (#618). The config
4744
- * options here (`Exclude`, `Disable`) are stable across releases, so pinning
4745
- * buys nothing.
4746
- */
4747
- function createConfig$4() {
4748
- return stringifyJson(editorconfig_checker_default);
4749
- }
4750
- //#endregion
4751
- //#region src/templates/configs/pre-commit/pre-commit
4752
- var pre_commit_default = "# Run GitLeaks against staged changes only — a full `gitleaks git` history\n# scan re-flags every pre-existing (already-merged) finding on every commit,\n# permanently blocking the repo once any false positive lands in history.\n# CI's super-linter GITLEAKS validator is likewise scoped to changed files.\ngitleaks protect --staged --no-banner\n\n# Exit with non-zero if secrets are detected by GitLeaks\nif [ $? -ne 0 ]; then\n echo \"Secrets detected by GitLeaks, aborting commit!\"\n exit 1\nfi\n\n# Run lint-staged with a custom config\nnpx lint-staged --config @theholocron/lint-staged-config\n";
4753
- //#endregion
4754
- //#region src/templates/configs/pre-commit/create-config.ts
4755
- const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
4756
- /**
4757
- * `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
4758
- * lint-staged before every commit. Installed by `holocron repo setup` when hooks
4759
- * are enabled (default for `protection: "strict"`). `git commit --no-verify`
4760
- * bypasses it.
4761
- *
4762
- * `gitleaks protect --staged` (not `gitleaks git`) is deliberate: the latter
4763
- * scans the entire commit history on every invocation, so once any false
4764
- * positive lands in a past commit — a fake test token, an example key in a
4765
- * doc — every future commit is permanently blocked, regardless of what's
4766
- * actually staged.
4767
- */
4768
- function createConfig$3() {
4769
- return `${workflowHeader$5("shebang")}${pre_commit_default}`;
4770
- }
4771
- //#endregion
4772
- //#region src/templates/configs/pre-push/pre-push
4773
- 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";
4774
- //#endregion
4775
- //#region src/templates/configs/pre-push/create-config.ts
4776
- const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
4777
- /**
4778
- * `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
4779
- * before every push. Installed by `holocron repo setup` when hooks are enabled
4780
- * (default for `protection: "strict"`). `git push --no-verify` bypasses it.
4781
- *
4782
- * @param holocronScript - the command that invokes the CLI. Defaults to
4783
- * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4784
- */
4785
- function createConfig$2(holocronScript = "pnpm exec holocron") {
4786
- return `${workflowHeader$4("shebang")}${pre_push_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4787
- }
4788
- //#endregion
4789
- //#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
4790
- var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\n# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to devmoji's own auto-discovery of a local devmoji.config.cjs when the\n# shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nDEVMOJI_CONFIG=\"node_modules/@theholocron/devmoji-config/dist/index.cjs\"\nif [ -f \"$DEVMOJI_CONFIG\" ]; then\n npx devmoji --config \"$DEVMOJI_CONFIG\" -e\nelse\n npx devmoji -e\nfi\n";
4791
- //#endregion
4792
- //#region src/templates/configs/prepare-commit-msg/create-config.ts
4793
- const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
4794
- function createConfig$1() {
4795
- return `${workflowHeader$3("shebang")}${prepare_commit_msg_default}`;
4796
- }
4797
- //#endregion
4798
- //#region src/templates/configs/yamllint/.yamlignore
4799
- var _yamlignore_default = "pnpm-lock.yaml\n";
4800
- //#endregion
4801
- //#region src/templates/configs/yamllint/.yamllint.yml
4802
- var _yamllint_default = "---\nextends: default\n\nignore-from-file:\n - .gitignore\n - .yamlignore\n\nrules:\n comments:\n min-spaces-from-content: 1\n require-starting-space: true\n\n comments-indentation: {}\n\n document-start:\n ignore: |\n .github/*.yml\n\n indentation:\n spaces: consistent\n indent-sequences: consistent\n\n line-length: disable\n";
4803
- //#endregion
4804
- //#region src/templates/configs/yamllint/create-config.ts
4805
- const { workflowHeader: workflowHeader$2 } = createHeader({ source: "packages/cli/src/templates/configs/yamllint/create-config.ts" });
4806
- function createConfig() {
4807
- return `${workflowHeader$2()}${_yamllint_default}`;
4808
- }
4809
- function createIgnoreConfig() {
4810
- return `${workflowHeader$2()}${_yamlignore_default}`;
4811
- }
4812
- //#endregion
4813
5030
  //#region src/templates/dco.yml
4814
5031
  var dco_default = "allowRemediationCommits:\n individual: true\n";
4815
5032
  //#endregion
4816
- //#region src/templates/dependabot.yml
4817
- var dependabot_default = "version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n commit-message:\n prefix: \"chore(deps)\"\n prefix-development: \"chore(deps-dev)\"\n groups:\n security-patches:\n applies-to: security-updates\n patterns:\n - \"*\"\n all-dependencies:\n update-types:\n - minor\n - patch\n\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n commit-message:\n prefix: \"chore(deps)\"\n groups:\n all-actions:\n patterns:\n - \"*\"\n";
4818
- //#endregion
4819
- //#region src/templates/labeler.yml
4820
- var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\nci:\n - \"^ci\"\n\ndependencies:\n - '^chore\\(deps'\n\ndocumentation:\n - \"^docs\"\n\nenhancement:\n - \"^feat\"\n\nperformance:\n - \"^perf\"\n\nrefactor:\n - \"^refactor\"\n\ntest:\n - \"^test\"\n";
4821
- //#endregion
4822
5033
  //#region src/commands/setup/agent-prompts-data.ts
4823
5034
  /**
4824
5035
  * Canonical AI engineering workflow role prompts.
@@ -4829,7 +5040,6 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
4829
5040
  *
4830
5041
  * Source: docs/wiki/specifications/ai-engineering-workflow.spec.md
4831
5042
  */
4832
- const DECISIONS_TEMPLATE = "---\nid: ADR-XXXX\ntitle: \"\"\nstatus: proposed\ndate: YYYY-MM-DD\nowners: []\nspecs: []\ndiscussion:\n github:\nsupersedes: []\nsuperseded-by: []\ntags: []\n---\n\n# [Short title of the decision]\n\n- Status: [proposed | accepted | rejected | deprecated | superseded by ADR-XXXX]\n- Date: YYYY-MM-DD\n\n## Context and Problem Statement\n\n2–3 sentences describing the situation that forced this decision.\n\n## Decision Drivers\n\n- [driver 1 — a constraint, goal, or value]\n- [driver 2]\n\n## Considered Options\n\n- [Option A]\n- [Option B]\n- [Option C — do nothing]\n\n## Decision Outcome\n\nChosen option: **[Option A]**, because [one-sentence justification].\n\n### Positive Consequences\n\n- …\n\n### Negative Consequences\n\n- …\n\n## Pros and Cons of the Options\n\n### [Option A]\n\n- Good, because [argument]\n- Bad, because [argument]\n\n### [Option B]\n\n- Good, because [argument]\n- Bad, because [argument]\n";
4833
5043
  const AGENT_PROMPTS = {
4834
5044
  "discovery.md": `# Discovery Agent
4835
5045
 
@@ -4958,10 +5168,14 @@ confidently, identify the missing information instead of inventing it.
4958
5168
 
4959
5169
  ## Format
4960
5170
 
4961
- Use MADR format. Copy the template and number sequentially:
5171
+ Use MADR format. Start from the \`adr\` skill's template,
5172
+ \`.agents/skills/adr/assets/template.md\`, and number the record sequentially.
5173
+ \`holocron repo setup\` installs that skill when \`adr\` is listed under
5174
+ \`skills\` in \`holocron.config\` — if the file is missing, add it there and
5175
+ re-run setup.
4962
5176
 
4963
5177
  \`\`\`sh
4964
- cp docs/wiki/decisions/template.md docs/wiki/decisions/000N-short-title.md
5178
+ cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
4965
5179
  \`\`\`
4966
5180
 
4967
5181
  Save to \`docs/wiki/decisions/NNNN-<slug>.md\`.
@@ -5168,10 +5382,13 @@ decision, the options that were considered, and the rationale for the choice mad
5168
5382
 
5169
5383
  ## Creating a new decision
5170
5384
 
5171
- Copy the template and number it sequentially:
5385
+ Start from the template that ships with the \`adr\` skill,
5386
+ \`.agents/skills/adr/assets/template.md\` (installed by \`holocron repo setup\`
5387
+ when \`adr\` is listed under \`skills\` in \`holocron.config\`), and number the
5388
+ record sequentially:
5172
5389
 
5173
5390
  \`\`\`sh
5174
- cp docs/wiki/decisions/template.md docs/wiki/decisions/000N-short-title.md
5391
+ cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
5175
5392
  \`\`\`
5176
5393
 
5177
5394
  \`madr\` has no CLI — creation is manual. Frontmatter is validated in CI
@@ -5353,7 +5570,6 @@ async function writeIfAbsent(filePath, content) {
5353
5570
  async function installEngineeringStructure({ repoRoot }) {
5354
5571
  const results = [];
5355
5572
  const writes = [
5356
- [join(repoRoot, "docs/wiki/decisions/template.md"), DECISIONS_TEMPLATE],
5357
5573
  [join(repoRoot, "docs/wiki/decisions/README.md"), DECISIONS_README],
5358
5574
  [join(repoRoot, "docs/wiki/standards/README.md"), STANDARDS_README],
5359
5575
  [join(repoRoot, "docs/wiki/specifications/README.md"), SPECIFICATIONS_README]
@@ -5376,7 +5592,7 @@ const BALANCED_REPO_SETTINGS = {
5376
5592
  has_wiki: false
5377
5593
  };
5378
5594
  //#endregion
5379
- //#region src/commands/setup/run-step.ts
5595
+ //#region src/commands/setup/run-step/run-step.ts
5380
5596
  async function runStep(capability, step, dryRun, body, opts = {}) {
5381
5597
  const result = await execStep(capability, step, dryRun, body, opts);
5382
5598
  const { status, message, reason } = result;
@@ -5447,7 +5663,7 @@ function formatStep(step) {
5447
5663
  return ` ${style.dim(`· ${label}`)}`;
5448
5664
  }
5449
5665
  //#endregion
5450
- //#region src/commands/setup/skills.ts
5666
+ //#region src/commands/setup/skills/skills.ts
5451
5667
  async function fetchExternalSkill(entry) {
5452
5668
  if (entry.sourceType !== "github")
5453
5669
  /* c8 ignore next */
@@ -5724,14 +5940,14 @@ async function runSetup(input) {
5724
5940
  if (loader.has("source") && (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
5725
5941
  const source = loader.get("source");
5726
5942
  steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
5727
- await source.writeRepoFile(".github/labeler.yml", `${workflowHeader$1()}${labeler_default}`);
5943
+ await source.writeRepoFile(".github/labeler.yml", createLabeler());
5728
5944
  }));
5729
5945
  print(formatStep(steps[steps.length - 1]));
5730
5946
  }
5731
5947
  if (loader.has("source") && effectivePreset !== "none") {
5732
5948
  const source = loader.get("source");
5733
5949
  steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
5734
- await source.writeRepoFile(".github/dependabot.yml", `${workflowHeader$1()}${dependabot_default}`);
5950
+ await source.writeRepoFile(".github/dependabot.yml", createDependabot());
5735
5951
  }));
5736
5952
  print(formatStep(steps[steps.length - 1]));
5737
5953
  steps.push(await runStep("source", "write .github/dco.yml", dryRun, async () => {
@@ -5741,54 +5957,50 @@ async function runSetup(input) {
5741
5957
  }
5742
5958
  if (loader.has("source")) {
5743
5959
  const source = loader.get("source");
5744
- steps.push(await runStep("source", "write .github/config.yml", dryRun, async () => {
5745
- await source.writeRepoFile(".github/config.yml", `${workflowHeader$1()}${config_default}`);
5746
- }));
5747
- print(formatStep(steps[steps.length - 1]));
5748
5960
  steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
5749
- await source.writeRepoFile(".alexrc.json", createRcConfig());
5961
+ await source.writeRepoFile(".alexrc.json", createAlexrc());
5750
5962
  }));
5751
5963
  print(formatStep(steps[steps.length - 1]));
5752
5964
  steps.push(await runStep("source", "write .alexignore", dryRun, async () => {
5753
- await source.writeRepoFile(".alexignore", createIgnoreConfig$1());
5965
+ await source.writeRepoFile(".alexignore", createAlexignore());
5754
5966
  }));
5755
5967
  print(formatStep(steps[steps.length - 1]));
5756
5968
  steps.push(await runStep("source", "write .yamllint.yml", dryRun, async () => {
5757
- await source.writeRepoFile(".yamllint.yml", createConfig());
5969
+ await source.writeRepoFile(".yamllint.yml", createYamllint());
5758
5970
  }));
5759
5971
  print(formatStep(steps[steps.length - 1]));
5760
5972
  steps.push(await runStep("source", "write .yamlignore", dryRun, async () => {
5761
- await source.writeRepoFile(".yamlignore", createIgnoreConfig());
5973
+ await source.writeRepoFile(".yamlignore", createYamlignore());
5762
5974
  }));
5763
5975
  print(formatStep(steps[steps.length - 1]));
5764
5976
  steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
5765
- await source.writeRepoFile(".editorconfig", createConfig$5());
5977
+ await source.writeRepoFile(".editorconfig", createEditorconfig());
5766
5978
  }));
5767
5979
  print(formatStep(steps[steps.length - 1]));
5768
5980
  steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
5769
- await source.writeRepoFile(".editorconfig-checker.json", createConfig$4());
5981
+ await source.writeRepoFile(".editorconfig-checker.json", createEditorconfigChecker());
5770
5982
  }));
5771
5983
  print(formatStep(steps[steps.length - 1]));
5772
5984
  steps.push(await runStep("source", "write devmoji.config.cjs", dryRun, async () => {
5773
- await source.writeRepoFile("devmoji.config.cjs", createConfig$6());
5985
+ await source.writeRepoFile("devmoji.config.cjs", createDevmoji());
5774
5986
  }));
5775
5987
  print(formatStep(steps[steps.length - 1]));
5776
5988
  steps.push(await runStep("source", "write .husky/prepare-commit-msg", dryRun, async () => {
5777
- await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
5989
+ await source.writeRepoFile(".husky/prepare-commit-msg", createPrepareCommitMsg());
5778
5990
  }));
5779
5991
  print(formatStep(steps[steps.length - 1]));
5780
5992
  steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5781
- await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5993
+ await source.writeRepoFile(".husky/commit-msg", createCommitMsg(config.holocronScript));
5782
5994
  }));
5783
5995
  print(formatStep(steps[steps.length - 1]));
5784
5996
  if (hooksEnabled) {
5785
5997
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5786
- await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
5998
+ await source.writeRepoFile(".husky/pre-push", createPrePush(config.holocronScript));
5787
5999
  return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
5788
6000
  }));
5789
6001
  print(formatStep(steps[steps.length - 1]));
5790
6002
  steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
5791
- await source.writeRepoFile(".husky/pre-commit", createConfig$3());
6003
+ await source.writeRepoFile(".husky/pre-commit", createPreCommit());
5792
6004
  return "runs GitLeaks (staged-only) + lint-staged before commit";
5793
6005
  }));
5794
6006
  print(formatStep(steps[steps.length - 1]));
@@ -6304,7 +6516,7 @@ async function ensurePrepareScript(repoRoot) {
6304
6516
  return true;
6305
6517
  }
6306
6518
  //#endregion
6307
- //#region src/commands/skills.ts
6519
+ //#region src/commands/skills/skills.ts
6308
6520
  /**
6309
6521
  * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
6310
6522
  *
@@ -6403,7 +6615,7 @@ function runSkillsUpdate(input) {
6403
6615
  return { status: exitCode === 0 ? "ok" : "fail" };
6404
6616
  }
6405
6617
  //#endregion
6406
- //#region src/commands/sync-readme.ts
6618
+ //#region src/commands/sync-readme/sync-readme.ts
6407
6619
  const MARKER_INSTALL_START = "<!-- holocron:installation -->";
6408
6620
  const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
6409
6621
  const REGISTRY_MAP = {
@@ -6559,9 +6771,13 @@ async function runSyncReadme(input) {
6559
6771
  };
6560
6772
  }
6561
6773
  //#endregion
6562
- //#region src/commands/sync-wiki.ts
6774
+ //#region src/commands/sync-wiki/sync-wiki.ts
6563
6775
  function resolveToken(input) {
6564
- return input.token ?? input.context.cliToken ?? process.env.HOLOCRON_READ_TOKEN ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
6776
+ return input.token ?? input.context.cliToken ?? firstEnv(env, [
6777
+ "HOLOCRON_READ_TOKEN",
6778
+ "GH_TOKEN",
6779
+ "GITHUB_TOKEN"
6780
+ ]);
6565
6781
  }
6566
6782
  function deriveBasepath(domain, repoName) {
6567
6783
  if (domain) {
@@ -6772,7 +6988,7 @@ async function runSyncWiki(input) {
6772
6988
  }
6773
6989
  }
6774
6990
  //#endregion
6775
- //#region src/commands/sync.ts
6991
+ //#region src/commands/sync/sync.ts
6776
6992
  const { workflowHeader } = createHeader({
6777
6993
  source: "packages/astromech/src/thin-callers.ts",
6778
6994
  tool: "holocron repo sync"
@@ -7360,7 +7576,7 @@ async function writeWorkflowFile(repoRoot, filename, content) {
7360
7576
  await rename(tmpPath, join(dir, filename));
7361
7577
  }
7362
7578
  //#endregion
7363
- //#region src/telemetry/resolve.ts
7579
+ //#region src/observability/resolve-telemetry/resolve-telemetry.ts
7364
7580
  /**
7365
7581
  * Holocron-specific credential resolution for the telemetry sinks — the env
7366
7582
  * chain plus Holocron's own shipped fallbacks. This is **CLI policy**, not part
@@ -7384,18 +7600,18 @@ const FALLBACK_POSTHOG_PROJECT_TOKEN = "phc_AC4vFCvYzwnzmG7Vg5nEc3PZKZztoPyfKp4L
7384
7600
  const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
7385
7601
  /** Resolve the Sentry DSN. `""` → error telemetry disabled. */
7386
7602
  function resolveDsn() {
7387
- return env.get("HOLOCRON_SENTRY_DSN") ?? env.get("SENTRY_DSN") ?? FALLBACK_DSN;
7603
+ return holocronEnv.get("SENTRY_DSN") ?? FALLBACK_DSN;
7388
7604
  }
7389
7605
  /** Resolve the PostHog project key. `""` → usage analytics disabled. */
7390
7606
  function resolvePostHogKey() {
7391
- return env.get("HOLOCRON_POSTHOG_PROJECT_TOKEN") ?? env.get("POSTHOG_PROJECT_TOKEN") ?? FALLBACK_POSTHOG_PROJECT_TOKEN;
7607
+ return holocronEnv.get("POSTHOG_PROJECT_TOKEN") ?? FALLBACK_POSTHOG_PROJECT_TOKEN;
7392
7608
  }
7393
7609
  /** Resolve the PostHog ingest host. */
7394
7610
  function resolvePostHogHost() {
7395
- return env.get("HOLOCRON_POSTHOG_HOST") ?? env.get("POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST;
7611
+ return holocronEnv.get("POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST;
7396
7612
  }
7397
7613
  //#endregion
7398
- //#region src/telemetry.ts
7614
+ //#region src/observability/telemetry/telemetry.ts
7399
7615
  /**
7400
7616
  * CLI self-telemetry orchestration — errors (Sentry), usage analytics (PostHog).
7401
7617
  *
@@ -7406,7 +7622,7 @@ function resolvePostHogHost() {
7406
7622
  * installed when telemetry is off. Same seam the package's `Logger` uses for
7407
7623
  * Pino.
7408
7624
  *
7409
- * Activation is env-var + shipped-fallback, resolved here (`telemetry/resolve.ts`)
7625
+ * Activation is env-var + shipped-fallback, resolved here (`resolve-telemetry.ts`)
7410
7626
  * and passed to the sink — the adapters read no environment. The single kill
7411
7627
  * switch is `HOLOCRON_TELEMETRY=false` (legacy alias `NO_HOLOCRON_TELEMETRY`);
7412
7628
  * see ADR-0007 / ADR-0008.
@@ -7539,7 +7755,7 @@ async function flush() {
7539
7755
  analytics = new NoopAnalyticsSink();
7540
7756
  }
7541
7757
  //#endregion
7542
- //#region src/commands/sync-github.ts
7758
+ //#region src/commands/sync-github/sync-github.ts
7543
7759
  const DEFAULT_REPO = "theholocron/.github";
7544
7760
  /**
7545
7761
  * The file set `sync-github` pushes to `theholocron/.github` — the reusable
@@ -7779,7 +7995,7 @@ async function runSyncGithub(input) {
7779
7995
  });
7780
7996
  }
7781
7997
  //#endregion
7782
- //#region src/commands/upgrade-deps.ts
7998
+ //#region src/commands/upgrade-deps/upgrade-deps.ts
7783
7999
  /**
7784
8000
  * `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7785
8001
  * its `holocron.config.ts` up to the current major.
@@ -8007,7 +8223,7 @@ async function runUpgradeDeps(input = {}) {
8007
8223
  };
8008
8224
  }
8009
8225
  //#endregion
8010
- //#region src/commands/upgrade-node.ts
8226
+ //#region src/commands/upgrade-node/upgrade-node.ts
8011
8227
  const SKIP_DIRS = [
8012
8228
  "node_modules",
8013
8229
  ".git",
@@ -8185,268 +8401,45 @@ async function runUpgradeNode(input) {
8185
8401
  to,
8186
8402
  updated: updated.length,
8187
8403
  status
8188
- }, "upgrade node: done");
8189
- return {
8190
- status,
8191
- updated
8192
- };
8193
- }
8194
- //#endregion
8195
- //#region src/commit-lint/lint-message.ts
8196
- /**
8197
- * Lints one commit message string against an already-loaded commitlint
8198
- * config. The genuinely environment-independent half of commit-message
8199
- * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
8200
- * caller (Sentinel isolates itself from any ambient config since it runs
8201
- * standalone, away from a real repo checkout; `lintCommitMsgFile` below
8202
- * just lets cosmiconfig discover the invoking repo's own
8203
- * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
8204
- * but linting one message against a loaded config is identical either way.
8205
- *
8206
- * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
8207
- * itself uses internally — never a reimplementation of commitlint's rules.
8208
- */
8209
- /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
8210
- function selectParserOpts(parserPreset) {
8211
- return parserPreset?.parserOpts;
8212
- }
8213
- async function lintCommitMessage(message, loaded) {
8214
- const opts = {
8215
- /* istanbul ignore next -- see comment above */
8216
- parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
8217
- plugins: loaded.plugins,
8218
- /* istanbul ignore next -- see comment above */
8219
- ignores: loaded.ignores ?? [],
8220
- defaultIgnores: loaded.defaultIgnores !== false
8221
- };
8222
- const outcome = await lint(message, loaded.rules, opts);
8223
- if (outcome.valid) return [];
8224
- return outcome.errors.map((error) => ({
8225
- rule: error.name,
8226
- message: error.message
8227
- }));
8228
- }
8229
- //#endregion
8230
- //#region src/commit-lint/lint-commit-msg-file.ts
8231
- /**
8232
- * `holocron run lint commit-msg <file>` — a real programmatic replacement for
8233
- * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
8234
- * (holocron#789). Matches that CLI's own contract exactly: `file` is a
8235
- * path to the commit message being written (the arg git's `commit-msg`
8236
- * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
8237
- * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
8238
- * discovers the invoking repo's real `commitlint.config.*` file, exactly
8239
- * as `commitlint --edit` (no `--config` flag) already does today. Unlike
8240
- * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
8241
- * runs inside a real repo checkout with a real `node_modules`, not
8242
- * standalone on a server.
8243
- */
8244
- async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8245
- const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
8246
- return {
8247
- valid: violations.length === 0,
8248
- violations
8249
- };
8250
- }
8251
- //#endregion
8252
- //#region src/config/load-config.ts
8253
- /**
8254
- * `holocron.config.{ts,js,mjs,cjs,json}` loader.
8255
- *
8256
- * File discovery + module loading (typed configs via `tsx`, no build
8257
- * step) is delegated to `@theholocron/datapad`. This module keeps the
8258
- * holocron-specific parts: filling `name` / `repo.name` defaults and
8259
- * running the config through `resolveConfig`.
8260
- *
8261
- * Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
8262
- * see ADR-0010 / issue #75 / #81.
8263
- */
8264
- /**
8265
- * Read + parse + resolve `holocron.config.*` starting from the given
8266
- * directory, walking up through ancestors to the nearest `.git` boundary
8267
- * when `cwd` itself has no match — every command is usable from any
8268
- * subdirectory of a repo, not only its exact root (see
8269
- * `@theholocron/datapad`'s own doc comment for the stop conditions).
8270
- * Throws {@link ConfigFileError} when nothing is found or a file cannot
8271
- * be loaded, or `ConfigError` when the config fails schema checks.
8272
- */
8273
- async function loadConfig(cwd) {
8274
- const found = await loadConfigFile({
8275
- cwd,
8276
- name: "holocron",
8277
- walkUp: true
8278
- });
8279
- if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
8404
+ }, "upgrade node: done");
8280
8405
  return {
8281
- resolved: resolveConfig(await deriveDefaults(found)),
8282
- filepath: found.filepath
8406
+ status,
8407
+ updated
8283
8408
  };
8284
8409
  }
8285
- async function deriveDefaults(found) {
8286
- const configDir = dirname(found.filepath);
8287
- const result = { ...found.config };
8288
- if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
8289
- if (result.repo && !result.repo.name) {
8290
- const remote = await readGitRemote(configDir);
8291
- if (remote?.host === "github.com") result.repo = {
8292
- ...result.repo,
8293
- name: remote.slug
8294
- };
8295
- }
8296
- const manifest = await loadTasksConfig(configDir, { holocron: found });
8297
- if (manifest.tasks) result.tasks = manifest.tasks;
8298
- if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
8299
- if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
8300
- if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
8301
- if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
8302
- return result;
8303
- }
8304
- async function readPackageJsonName(dir) {
8305
- const name = (await readPackageJson(dir))?.name;
8306
- return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
8307
- }
8308
8410
  //#endregion
8309
- //#region src/interactive-menu.ts
8411
+ //#region src/interactive-menu/child-argv/child-argv.ts
8310
8412
  /**
8311
- * The interactive fallback — replaces every "run --help to see commands"
8312
- * dead end with a prompt. Three layers, all driven by {@link COMMAND_REGISTRY}:
8313
- *
8314
- * 1. `holocron` with no command → searchable picker over every command
8315
- * 2. a parent command with no sub → `select()` over that group's commands
8316
- * 3. a leaf command missing a → `input()` / `select()` per positional
8317
- * required positional
8318
- *
8319
- * Layers 1–2 are driven from `cli.ts`'s `$0` handlers via {@link launchMenu}:
8320
- * pick a command, prompt for its required positionals, then **spawn a fresh
8321
- * `holocron <command> …` child process** (`stdio: "inherit"`) rather than
8322
- * re-entering yargs' own parse pipeline — see the spec's "spawn vs re-parse"
8323
- * section (theholocron/holocron#438) for why. Layer 3 is driven directly from
8324
- * each leaf command's own handler in `cli.ts`, calling
8325
- * {@link promptForPositionals} for just that one command — no spawn needed,
8326
- * the handler already has everything else it needs.
8327
- *
8328
- * For a command whose handler honours `--dry-run` ({@link
8329
- * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8330
- * dry run doesn't mutate anything, so there's nothing to ask permission
8331
- * for — then, once that preview comes back clean and found something to
8332
- * do, asks the one real decision: "proceed with the real run?". A preview
8333
- * that finds nothing to do skips that question too. `--skip-dry-run` opts
8334
- * a top-level invocation out of the preview entirely, straight to the
8335
- * real run, for when you already know what you want.
8413
+ * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
8414
+ * menu-launched child should inherit from the parent invocation. Everything
8415
+ * else (command-specific options) was never captured by the top-level `$0`
8416
+ * handler in the first place, so there's nothing else to forward.
8336
8417
  *
8337
- * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
8338
- * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
8339
- * hanging on `@inquirer/prompts` (which otherwise throws its own
8340
- * `ExitPromptError` the moment stdin closes) — `cli.ts`'s `USER_FACING_ERRORS`
8341
- * catch prints its message cleanly, restoring the old demandCommand-style
8342
- * hard failure for non-interactive callers.
8343
- */
8344
- /** Raised when a prompt is needed but stdin isn't a TTY — see the module doc. */
8345
- var NonInteractiveError = class extends Error {
8346
- name = "NonInteractiveError";
8347
- };
8348
- const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
8349
- /**
8350
- * This repo's own resolved task manifest, filtered to what's actually
8351
- * runnable locally — `Astromech.runnableTasks()`, reused rather than
8352
- * reimplemented (deliberately *not* `packageScripts()`, whose
8353
- * `syncScripts: false` short-circuit returns `{}` for repos — this one
8354
- * included — that opt out of generated `package.json` scripts but still
8355
- * have real runnable tasks). Empty (never throws) when there's no config to
8356
- * resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
8357
- * as "run ›" simply having no children.
8358
- */
8359
- async function listRunnableTasks(argv) {
8360
- const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
8361
- const config = await loadTasksConfig(cwd).catch(() => void 0);
8362
- return createAstromech({
8363
- cwd,
8364
- config
8365
- }).runnableTasks();
8366
- }
8367
- /**
8368
- * Namespace display order for the `run` menu — deliberately its own thing,
8369
- * not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
8370
- * signal first" in an automated gate, a different goal from "how a person
8371
- * would naturally work through a repo" (build it, clean up its style, check
8372
- * it's secure, test it, validate it, document it). A namespace absent here
8373
- * (a repo-specific one this list doesn't know about) sorts after every
8374
- * named one, in whatever order it was first seen.
8375
- */
8376
- const RUN_MENU_NAMESPACE_ORDER = [
8377
- "delivery",
8378
- "sourceQuality",
8379
- "security",
8380
- "verification",
8381
- "platform",
8382
- "knowledge"
8383
- ];
8384
- /**
8385
- * Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
8386
- * groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
8387
- * within each group is `taskNames`' own order (already declaration order,
8388
- * from {@link listRunnableTasks}). A name with no `.` at all (defensive;
8389
- * every real registry task has a namespace) is its own bucket, keyed by the
8390
- * full name, sorted after every namespaced group.
8418
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8419
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
8420
+ * force it back off for the real-run spawn that follows, regardless of
8421
+ * what was on the original invocation.
8391
8422
  */
8392
- function groupByNamespace(taskNames) {
8393
- const groups = /* @__PURE__ */ new Map();
8394
- for (const name of taskNames) {
8395
- const dot = name.indexOf(".");
8396
- const key = dot === -1 ? name : name.slice(0, dot);
8397
- const list = groups.get(key);
8398
- if (list) list.push(name);
8399
- else groups.set(key, [name]);
8400
- }
8401
- const ordered = /* @__PURE__ */ new Map();
8402
- const byMenuOrder = (a, b) => {
8403
- const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
8404
- const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
8405
- if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
8406
- return 0;
8407
- };
8408
- for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8409
- return ordered;
8423
+ function forwardedFlags(argv, dryRunOverride) {
8424
+ const out = [];
8425
+ const tokens = argv.token;
8426
+ for (const t of tokens ?? []) out.push("--token", t);
8427
+ if (typeof argv.org === "string") out.push("--org", argv.org);
8428
+ if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8429
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8430
+ return out;
8410
8431
  }
8411
- /**
8412
- * Dynamically-built `run` sub-entries for every locally-runnable task
8413
- * ({@link listRunnableTasks}), grouped by namespace and merged into
8414
- * `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
8415
- * itself, since the task set is per-repo, not a static surface this module
8416
- * can hand-enumerate.
8417
- *
8418
- * Each namespace becomes a group with a leading "<namespace> (run it)" item
8419
- * (`holocron run <namespace>` — every task in it, via
8420
- * `Astromech.runGroup()`) alongside each of its individual tasks, the exact
8421
- * same "a group that's also runnable" shape `menuItems` already renders for
8422
- * `repo sync` / `deploy` — reached here for free by using a space, not the
8423
- * task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
8424
- * the real dotted invocation `buildChildArgv` actually spawns).
8425
- */
8426
- function buildRunMenuEntries(taskNames) {
8427
- const entries = [];
8428
- for (const [namespace, tasks] of groupByNamespace(taskNames)) {
8429
- const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
8430
- if (isRealNamespace) entries.push({
8431
- name: `run ${namespace}`,
8432
- description: `Run every ${namespace}.* task in sequence`,
8433
- positionals: [],
8434
- supportsDryRun: true,
8435
- spawnArgs: ["run", namespace]
8436
- });
8437
- for (const task of tasks) {
8438
- const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
8439
- entries.push({
8440
- name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
8441
- description: `Run ${task}`,
8442
- positionals: [],
8443
- supportsDryRun: true,
8444
- spawnArgs: ["run", task]
8445
- });
8446
- }
8447
- }
8448
- return entries;
8432
+ /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8433
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8434
+ return [
8435
+ ...entry.spawnArgs ?? entry.name.split(" "),
8436
+ ...positionals,
8437
+ ...forwardedFlags(parentArgv, dryRunOverride)
8438
+ ];
8449
8439
  }
8440
+ //#endregion
8441
+ //#region src/interactive-menu/command-registry/command-registry.ts
8442
+ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
8450
8443
  /**
8451
8444
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
8452
8445
  * yargs registrations (this module does not introspect yargs at runtime).
@@ -8684,6 +8677,9 @@ function getEntry(name) {
8684
8677
  if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
8685
8678
  return entry;
8686
8679
  }
8680
+ //#endregion
8681
+ //#region src/interactive-menu/menu-items/menu-items.ts
8682
+ /** Prefix of a menu item's `value` that opens a submenu rather than running a command. */
8687
8683
  const GROUP = "group:";
8688
8684
  /** The "← back" item's value; only offered below the first level. */
8689
8685
  const BACK = "back";
@@ -8729,105 +8725,16 @@ function menuItems(entries, path = []) {
8729
8725
  const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
8730
8726
  items.push({
8731
8727
  name: `${word} ›`,
8732
- value: `${GROUP}${full}`,
8733
- description: children.join(", "),
8734
- haystack: haystackOf(family)
8735
- });
8736
- }
8737
- }
8738
- return items;
8739
- }
8740
- /**
8741
- * Searchable autocomplete over one menu level's `items`. `select()` would work too
8742
- * at these list sizes, but `search()` degrades gracefully as the surface grows and
8743
- * costs nothing when it doesn't. Resolves to the picked item's `value`.
8744
- */
8745
- async function pickMenuItem(items, message = "What would you like to do?") {
8746
- return search({
8747
- message,
8748
- source: (term) => searchChoices(items, term)
8749
- });
8750
- }
8751
- /**
8752
- * `@inquirer/search`'s `source` callback, factored out as a plain function —
8753
- * unit-testable directly instead of only through a mocked `search()` call.
8754
- * Empty/undefined `term` (nothing typed yet) returns every item.
8755
- */
8756
- function searchChoices(items, term) {
8757
- const needle = term?.toLowerCase();
8758
- const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
8759
- const rank = (i) => {
8760
- if (!needle) return 0;
8761
- const name = i.name.toLowerCase();
8762
- return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
8763
- };
8764
- return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
8765
- name: i.name,
8766
- value: i.value,
8767
- description: i.description
8768
- }));
8769
- }
8770
- /**
8771
- * For each of `entry.positionals` not already present in `argv`, prompts for
8772
- * it (`input()` or `select()`, per the positional's `type`) and returns the
8773
- * resolved values in positional order — ready to append to a spawned child's
8774
- * argv, or to use directly in the current handler (Layer 3).
8775
- *
8776
- * Throws {@link NonInteractiveError} the first time it would need to prompt
8777
- * on a non-TTY stdin, and again if a `select` positional's choices resolve
8778
- * empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
8779
- */
8780
- async function promptForPositionals(entry, argv) {
8781
- const out = [];
8782
- for (const positional of entry.positionals) {
8783
- const existing = argv[positional.key];
8784
- if (existing !== void 0 && existing !== "") {
8785
- out.push(String(existing));
8786
- continue;
8787
- }
8788
- if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
8789
- if (positional.type === "select") {
8790
- const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
8791
- if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
8792
- out.push(await select({
8793
- message: positional.message,
8794
- choices: choices.map((c) => ({ value: c }))
8795
- }));
8796
- } else out.push(await input({
8797
- message: positional.message,
8798
- ...positional.validate ? { validate: positional.validate } : {}
8799
- }));
8800
- }
8801
- return out;
8802
- }
8803
- /**
8804
- * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
8805
- * menu-launched child should inherit from the parent invocation. Everything
8806
- * else (command-specific options) was never captured by the top-level `$0`
8807
- * handler in the first place, so there's nothing else to forward.
8808
- *
8809
- * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8810
- * dry-run offer needs to force `--dry-run` on for the preview spawn and
8811
- * force it back off for the real-run spawn that follows, regardless of
8812
- * what was on the original invocation.
8813
- */
8814
- function forwardedFlags(argv, dryRunOverride) {
8815
- const out = [];
8816
- const tokens = argv.token;
8817
- for (const t of tokens ?? []) out.push("--token", t);
8818
- if (typeof argv.org === "string") out.push("--org", argv.org);
8819
- if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8820
- if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8821
- return out;
8822
- }
8823
- /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8824
- function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8825
- return [
8826
- ...entry.spawnArgs ?? entry.name.split(" "),
8827
- ...positionals,
8828
- ...forwardedFlags(parentArgv, dryRunOverride)
8829
- ];
8728
+ value: `${GROUP}${full}`,
8729
+ description: children.join(", "),
8730
+ haystack: haystackOf(family)
8731
+ });
8732
+ }
8733
+ }
8734
+ return items;
8830
8735
  }
8736
+ //#endregion
8737
+ //#region src/interactive-menu/launch-menu/launch-menu.ts
8831
8738
  /**
8832
8739
  * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8833
8740
  * its handler knows this specific `--dry-run` came from `launchMenu`'s own
@@ -8850,353 +8757,200 @@ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8850
8757
  * once the child exits. `extraEnv` layers on top — used only for {@link
8851
8758
  * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8852
8759
  */
8853
- function spawnChild(args, extraEnv) {
8854
- return new Promise((resolve) => {
8855
- const child = spawn(process.execPath, [process.argv[1], ...args], {
8856
- stdio: "inherit",
8857
- env: {
8858
- ...process.env,
8859
- NO_UPDATE_NOTIFIER: "1",
8860
- ...extraEnv
8861
- }
8862
- });
8863
- child.on("exit", (code) => resolve(code ?? 0));
8864
- child.on("error", () => resolve(1));
8865
- });
8866
- }
8867
- /**
8868
- * Whether `launchMenu` should auto-preview a picked command before running
8869
- * it for real — only when the command's handler actually honours
8870
- * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8871
- * invocation didn't pass `--skip-dry-run` to opt out of the preview
8872
- * entirely (an escape hatch for "I already know what I want, skip
8873
- * straight to the real run").
8874
- */
8875
- function shouldPreviewDryRun(entry, parentArgv) {
8876
- return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8877
- }
8878
- /**
8879
- * Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
8880
- * `[]` for the top), prompt for the picked command's required positionals, spawn
8881
- * it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
8882
- * the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
8883
- * parent's own telemetry flush / update-notifier tail still runs. The parent's
8884
- * own `command_completed` event fires with command name "unknown" (the middleware
8885
- * ran before any command was picked) — left as-is rather than suppressed; it's a
8886
- * real, useful signal ("the menu got used").
8887
- *
8888
- * Picking a group opens its submenu, which has a "← back" item; a command ends
8889
- * the walk. See {@link menuItems} for how a level is built.
8890
- *
8891
- * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8892
- * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8893
- * this always previews first — a dry run doesn't mutate anything, so
8894
- * there's nothing to ask permission for. A preview that exits non-zero
8895
- * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8896
- * would change) stops here too, skipping the follow-up question since the
8897
- * real run would have nothing to do either. Otherwise it asks "proceed
8898
- * with the real run?" before the real spawn — the one actual decision
8899
- * left to make, since this one does mutate something.
8900
- */
8901
- async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
8902
- if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8903
- const stack = [startPath];
8904
- let picked;
8905
- while (!picked) {
8906
- const path = stack[stack.length - 1];
8907
- const items = menuItems(entries, path);
8908
- if (stack.length > 1) items.push({
8909
- name: "← back",
8910
- value: BACK,
8911
- description: "Return to the previous menu",
8912
- haystack: "back"
8913
- });
8914
- const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
8915
- if (value === "back") stack.pop();
8916
- else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
8917
- else picked = entries.find((e) => e.name === value);
8918
- }
8919
- const positionals = await promptForPositionals(picked, {});
8920
- if (shouldPreviewDryRun(picked, parentArgv)) {
8921
- const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8922
- if (dryExit === 2) {
8923
- process.exitCode = 0;
8924
- return;
8925
- }
8926
- if (dryExit !== 0) {
8927
- process.exitCode = dryExit;
8928
- return;
8929
- }
8930
- if (!await confirm({
8931
- message: "Dry run complete — proceed with the real run?",
8932
- default: false
8933
- })) {
8934
- process.exitCode = 0;
8935
- return;
8936
- }
8937
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8938
- return;
8939
- }
8940
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
8941
- }
8942
- //#endregion
8943
- //#region src/ui/json-view.ts
8944
- function isContainer(value) {
8945
- return typeof value === "object" && value !== null;
8946
- }
8947
- function childrenOf(value) {
8948
- return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
8949
- }
8950
- function scalar(value) {
8951
- if (typeof value === "string") return chalk.green(JSON.stringify(value));
8952
- if (typeof value === "number") return chalk.yellow(String(value));
8953
- if (typeof value === "boolean") return chalk.magenta(String(value));
8954
- return chalk.dim(String(value === void 0 ? null : value));
8955
- }
8956
- function summary(value) {
8957
- const n = childrenOf(value).length;
8958
- const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
8959
- return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
8960
- }
8961
- /**
8962
- * Paths of every container deeper than `depth` levels are left out, so those
8963
- * render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
8964
- */
8965
- function expandToDepth(value, depth) {
8966
- const out = /* @__PURE__ */ new Set();
8967
- const walk = (v, path, level) => {
8968
- if (!isContainer(v) || level >= depth) return;
8969
- out.add(path);
8970
- for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
8971
- };
8972
- walk(value, "", 0);
8973
- return out;
8974
- }
8975
- /** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
8976
- function renderTree(value, { expanded }) {
8977
- const lines = [];
8978
- const walk = (v, path, level, label, trailing) => {
8979
- const indent = " ".repeat(level);
8980
- const prefix = `${indent}${label}`;
8981
- if (!isContainer(v)) {
8982
- lines.push({
8983
- path,
8984
- text: `${prefix}${scalar(v)}${trailing}`,
8985
- foldable: false,
8986
- expanded: false,
8987
- level
8988
- });
8989
- return;
8990
- }
8991
- const kids = childrenOf(v);
8992
- const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
8993
- if (kids.length === 0) {
8994
- lines.push({
8995
- path,
8996
- text: `${prefix}${open}${close}${trailing}`,
8997
- foldable: false,
8998
- expanded: false,
8999
- level
9000
- });
9001
- return;
9002
- }
9003
- if (!expanded.has(path)) {
9004
- lines.push({
9005
- path,
9006
- text: `${prefix}${summary(v)}${trailing}`,
9007
- foldable: true,
9008
- expanded: false,
9009
- level
9010
- });
9011
- return;
9012
- }
9013
- lines.push({
9014
- path,
9015
- text: `${prefix}${open}`,
9016
- foldable: true,
9017
- expanded: true,
9018
- level
9019
- });
9020
- kids.forEach(([k, child], i) => {
9021
- const comma = i < kids.length - 1 ? "," : "";
9022
- const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
9023
- walk(child, `${path}/${k}`, level + 1, childLabel, comma);
9024
- });
9025
- lines.push({
9026
- path: `${path}#close`,
9027
- text: `${indent}${close}${trailing}`,
9028
- foldable: false,
9029
- expanded: false,
9030
- level
9031
- });
9032
- };
9033
- walk(value, "", 0, "", "");
9034
- return lines;
9035
- }
9036
- /** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
9037
- function reduceKey(state, key, value, rows) {
9038
- const lines = renderTree(value, { expanded: state.expanded });
9039
- const cur = lines[state.cursor];
9040
- let { cursor } = state;
9041
- const expanded = new Set(state.expanded);
9042
- switch (key) {
9043
- case "up":
9044
- cursor = Math.max(0, cursor - 1);
9045
- break;
9046
- case "down":
9047
- cursor = Math.min(lines.length - 1, cursor + 1);
9048
- break;
9049
- case "right":
9050
- if (cur?.foldable) expanded.add(cur.path);
9051
- break;
9052
- case "left":
9053
- if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
9054
- else if (cur) {
9055
- const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
9056
- if (parent >= 0) cursor = parent;
9057
- }
9058
- break;
9059
- case "expandAll": return {
9060
- cursor,
9061
- top: state.top,
9062
- expanded: expandToDepth(value, Infinity)
9063
- };
9064
- case "collapseAll": return {
9065
- cursor: 0,
9066
- top: 0,
9067
- expanded: /* @__PURE__ */ new Set()
9068
- };
9069
- }
9070
- const count = renderTree(value, { expanded }).length;
9071
- cursor = Math.min(cursor, count - 1);
9072
- let top = state.top;
9073
- if (cursor < top) top = cursor;
9074
- if (cursor >= top + rows) top = cursor - rows + 1;
9075
- return {
9076
- cursor,
9077
- top: Math.max(0, top),
9078
- expanded
9079
- };
9080
- }
9081
- const KEYMAP = {
9082
- "\x1B[A": "up",
9083
- k: "up",
9084
- "\x1B[B": "down",
9085
- j: "down",
9086
- "\x1B[C": "right",
9087
- l: "right",
9088
- "\r": "right",
9089
- " ": "right",
9090
- "\x1B[D": "left",
9091
- h: "left",
9092
- E: "expandAll",
9093
- C: "collapseAll",
9094
- q: "quit",
9095
- "\x1B": "quit",
9096
- "": "quit"
9097
- };
9098
- function draw(out, value, state, rows) {
9099
- const lines = renderTree(value, { expanded: state.expanded });
9100
- const view = lines.slice(state.top, state.top + rows).map((l, i) => {
9101
- const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
9102
- return state.top + i === state.cursor ? chalk.inverse(row) : row;
8760
+ function spawnChild(args, extraEnv) {
8761
+ return new Promise((resolve) => {
8762
+ const child = spawn(process.execPath, [process.argv[1], ...args], {
8763
+ stdio: "inherit",
8764
+ env: {
8765
+ ...process.env,
8766
+ NO_UPDATE_NOTIFIER: "1",
8767
+ ...extraEnv
8768
+ }
8769
+ });
8770
+ child.on("exit", (code) => resolve(code ?? 0));
8771
+ child.on("error", () => resolve(1));
9103
8772
  });
9104
- const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
9105
- out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
9106
8773
  }
9107
- async function runInteractive(value, depth) {
9108
- const { stdin, stdout } = process;
9109
- if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
9110
- const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
9111
- let state = {
9112
- cursor: 0,
9113
- top: 0,
9114
- expanded: expandToDepth(value, depth)
9115
- };
9116
- const wasRaw = stdin.isRaw;
9117
- stdin.setRawMode(true);
9118
- stdin.resume();
9119
- stdout.write("\x1B[?1049h\x1B[?25l");
9120
- const redraw = () => draw(stdout, value, state, rows());
9121
- stdout.on("resize", redraw);
9122
- try {
9123
- redraw();
9124
- await new Promise((resolve) => {
9125
- const onData = (buf) => {
9126
- const key = KEYMAP[buf.toString()];
9127
- if (key === "quit") {
9128
- stdin.off("data", onData);
9129
- resolve();
9130
- return;
9131
- }
9132
- if (key) {
9133
- state = reduceKey(state, key, value, rows());
9134
- redraw();
9135
- }
9136
- };
9137
- stdin.on("data", onData);
9138
- });
9139
- } finally {
9140
- stdout.off("resize", redraw);
9141
- stdout.write("\x1B[?25h\x1B[?1049l");
9142
- stdin.setRawMode(wasRaw);
9143
- stdin.pause();
9144
- }
8774
+ /**
8775
+ * Whether `launchMenu` should auto-preview a picked command before running
8776
+ * it for real — only when the command's handler actually honours
8777
+ * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8778
+ * invocation didn't pass `--skip-dry-run` to opt out of the preview
8779
+ * entirely (an escape hatch for "I already know what I want, skip
8780
+ * straight to the real run").
8781
+ */
8782
+ function shouldPreviewDryRun(entry, parentArgv) {
8783
+ return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
9145
8784
  }
9146
8785
  /**
9147
- * Print `value` as JSON. Non-TTY stdout always gets plain, valid
9148
- * `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
9149
- * coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
8786
+ * Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
8787
+ * `[]` for the top), prompt for the picked command's required positionals, spawn
8788
+ * it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
8789
+ * the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
8790
+ * parent's own telemetry flush / update-notifier tail still runs. The parent's
8791
+ * own `command_completed` event fires with command name "unknown" (the middleware
8792
+ * ran before any command was picked) — left as-is rather than suppressed; it's a
8793
+ * real, useful signal ("the menu got used").
8794
+ *
8795
+ * Picking a group opens its submenu, which has a "← back" item; a command ends
8796
+ * the walk. See {@link menuItems} for how a level is built.
8797
+ *
8798
+ * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8799
+ * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8800
+ * this always previews first — a dry run doesn't mutate anything, so
8801
+ * there's nothing to ask permission for. A preview that exits non-zero
8802
+ * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8803
+ * would change) stops here too, skipping the follow-up question since the
8804
+ * real run would have nothing to do either. Otherwise it asks "proceed
8805
+ * with the real run?" before the real spawn — the one actual decision
8806
+ * left to make, since this one does mutate something.
9150
8807
  */
9151
- async function viewJson(value, options = {}) {
9152
- const depth = options.depth ?? Infinity;
9153
- if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
9154
- if (!process.stdout.isTTY) {
9155
- console.log(JSON.stringify(value, null, 2));
8808
+ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
8809
+ if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8810
+ const stack = [startPath];
8811
+ let picked;
8812
+ while (!picked) {
8813
+ const path = stack[stack.length - 1];
8814
+ const items = menuItems(entries, path);
8815
+ if (stack.length > 1) items.push({
8816
+ name: "← back",
8817
+ value: BACK,
8818
+ description: "Return to the previous menu",
8819
+ haystack: "back"
8820
+ });
8821
+ const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
8822
+ if (value === "back") stack.pop();
8823
+ else if (value.startsWith("group:")) stack.push(value.slice(6).split(" "));
8824
+ else picked = entries.find((e) => e.name === value);
8825
+ }
8826
+ const positionals = await promptForPositionals(picked, {});
8827
+ if (shouldPreviewDryRun(picked, parentArgv)) {
8828
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8829
+ if (dryExit === 2) {
8830
+ process.exitCode = 0;
8831
+ return;
8832
+ }
8833
+ if (dryExit !== 0) {
8834
+ process.exitCode = dryExit;
8835
+ return;
8836
+ }
8837
+ if (!await confirm({
8838
+ message: "Dry run complete — proceed with the real run?",
8839
+ default: false
8840
+ })) {
8841
+ process.exitCode = 0;
8842
+ return;
8843
+ }
8844
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
9156
8845
  return;
9157
8846
  }
9158
- const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
9159
- console.log(lines.map((l) => l.text).join("\n"));
8847
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
9160
8848
  }
9161
8849
  //#endregion
9162
- //#region src/ui/open-path.ts
9163
- /** Platform-appropriate command to open a path in the OS's file browser. */
9164
- function openCommand(path) {
9165
- if (process.platform === "darwin") return {
9166
- cmd: "open",
9167
- args: [path]
9168
- };
9169
- if (process.platform === "win32") return {
9170
- cmd: "cmd",
9171
- args: [
9172
- "/c",
9173
- "start",
9174
- "",
9175
- path
9176
- ]
9177
- };
9178
- return {
9179
- cmd: "xdg-open",
9180
- args: [path]
8850
+ //#region src/interactive-menu/run-menu-entries/run-menu-entries.ts
8851
+ /**
8852
+ * This repo's own resolved task manifest, filtered to what's actually
8853
+ * runnable locally — `Astromech.runnableTasks()`, reused rather than
8854
+ * reimplemented (deliberately *not* `packageScripts()`, whose
8855
+ * `syncScripts: false` short-circuit returns `{}` for repos — this one
8856
+ * included — that opt out of generated `package.json` scripts but still
8857
+ * have real runnable tasks). Empty (never throws) when there's no config to
8858
+ * resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
8859
+ * as "run ›" simply having no children.
8860
+ */
8861
+ async function listRunnableTasks(argv) {
8862
+ const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
8863
+ const config = await loadTasksConfig(cwd).catch(() => void 0);
8864
+ return createAstromech({
8865
+ cwd,
8866
+ config
8867
+ }).runnableTasks();
8868
+ }
8869
+ /**
8870
+ * Namespace display order for the `run` menu — deliberately its own thing,
8871
+ * not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
8872
+ * signal first" in an automated gate, a different goal from "how a person
8873
+ * would naturally work through a repo" (build it, clean up its style, check
8874
+ * it's secure, test it, validate it, document it). A namespace absent here
8875
+ * (a repo-specific one this list doesn't know about) sorts after every
8876
+ * named one, in whatever order it was first seen.
8877
+ */
8878
+ const RUN_MENU_NAMESPACE_ORDER = [
8879
+ "delivery",
8880
+ "sourceQuality",
8881
+ "security",
8882
+ "verification",
8883
+ "platform",
8884
+ "knowledge"
8885
+ ];
8886
+ /**
8887
+ * Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
8888
+ * groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
8889
+ * within each group is `taskNames`' own order (already declaration order,
8890
+ * from {@link listRunnableTasks}). A name with no `.` at all (defensive;
8891
+ * every real registry task has a namespace) is its own bucket, keyed by the
8892
+ * full name, sorted after every namespaced group.
8893
+ */
8894
+ function groupByNamespace(taskNames) {
8895
+ const groups = /* @__PURE__ */ new Map();
8896
+ for (const name of taskNames) {
8897
+ const dot = name.indexOf(".");
8898
+ const key = dot === -1 ? name : name.slice(0, dot);
8899
+ const list = groups.get(key);
8900
+ if (list) list.push(name);
8901
+ else groups.set(key, [name]);
8902
+ }
8903
+ const ordered = /* @__PURE__ */ new Map();
8904
+ const byMenuOrder = (a, b) => {
8905
+ const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
8906
+ const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
8907
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
8908
+ return 0;
9181
8909
  };
8910
+ for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8911
+ return ordered;
9182
8912
  }
9183
8913
  /**
9184
- * Open `path` in the OS's default file browser (Finder / Explorer /
9185
- * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
9186
- * it outlives this process, and never throws when the opener binary isn't
9187
- * on PATH (e.g. no `xdg-open` in a minimal container).
8914
+ * Dynamically-built `run` sub-entries for every locally-runnable task
8915
+ * ({@link listRunnableTasks}), grouped by namespace and merged into
8916
+ * `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
8917
+ * itself, since the task set is per-repo, not a static surface this module
8918
+ * can hand-enumerate.
8919
+ *
8920
+ * Each namespace becomes a group with a leading "<namespace> (run it)" item
8921
+ * (`holocron run <namespace>` — every task in it, via
8922
+ * `Astromech.runGroup()`) alongside each of its individual tasks, the exact
8923
+ * same "a group that's also runnable" shape `menuItems` already renders for
8924
+ * `repo sync` / `deploy` — reached here for free by using a space, not the
8925
+ * task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
8926
+ * the real dotted invocation `buildChildArgv` actually spawns).
9188
8927
  */
9189
- function openPath(path) {
9190
- const { cmd, args } = openCommand(path);
9191
- const child = spawn(cmd, args, {
9192
- stdio: "ignore",
9193
- detached: true
9194
- });
9195
- child.on("error", () => {});
9196
- child.unref();
8928
+ function buildRunMenuEntries(taskNames) {
8929
+ const entries = [];
8930
+ for (const [namespace, tasks] of groupByNamespace(taskNames)) {
8931
+ const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
8932
+ if (isRealNamespace) entries.push({
8933
+ name: `run ${namespace}`,
8934
+ description: `Run every ${namespace}.* task in sequence`,
8935
+ positionals: [],
8936
+ supportsDryRun: true,
8937
+ spawnArgs: ["run", namespace]
8938
+ });
8939
+ for (const task of tasks) {
8940
+ const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
8941
+ entries.push({
8942
+ name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
8943
+ description: `Run ${task}`,
8944
+ positionals: [],
8945
+ supportsDryRun: true,
8946
+ spawnArgs: ["run", task]
8947
+ });
8948
+ }
8949
+ }
8950
+ return entries;
9197
8951
  }
9198
8952
  //#endregion
9199
- //#region src/update-notifier.ts
8953
+ //#region src/update-notifier/update-notifier.ts
9200
8954
  const PACKAGE_NAME = "@theholocron/cli";
9201
8955
  const CACHE_TTL_MS = 864e5;
9202
8956
  const FETCH_TIMEOUT_MS = 3e3;