@theholocron/cli 5.0.0-alpha.152 → 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,8 +4,9 @@ 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";
@@ -14,14 +15,16 @@ 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,11 +32,7 @@ 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
  /**
@@ -51,7 +50,7 @@ function makeEnv(source) {
51
50
  return createEnvLookup(source);
52
51
  }
53
52
  //#endregion
54
- //#region src/auth/keyring.ts
53
+ //#region src/auth/keyring/keyring.ts
55
54
  /**
56
55
  * Keyring-backed bootstrap credential store.
57
56
  *
@@ -121,7 +120,7 @@ function listStoredProviders() {
121
120
  }
122
121
  }
123
122
  //#endregion
124
- //#region src/auth/auth-resolver.ts
123
+ //#region src/auth/auth-resolver/auth-resolver.ts
125
124
  /**
126
125
  * Build a strict, single-feature token resolver.
127
126
  *
@@ -139,7 +138,7 @@ function createFeatureResolver(config) {
139
138
  };
140
139
  }
141
140
  //#endregion
142
- //#region src/auth/token-args.ts
141
+ //#region src/auth/token-args/token-args.ts
143
142
  var TokenParseError = class extends Error {
144
143
  name = "TokenParseError";
145
144
  };
@@ -177,7 +176,7 @@ function parseTokenArgs(tokens) {
177
176
  return result;
178
177
  }
179
178
  //#endregion
180
- //#region src/plugin/capabilities.ts
179
+ //#region src/plugin/capabilities/capabilities.ts
181
180
  const CARDINALITY = {
182
181
  source: "single",
183
182
  ci: "single",
@@ -205,7 +204,7 @@ const CARDINALITY = {
205
204
  */
206
205
  const REQUIRED_CAPABILITIES = [];
207
206
  //#endregion
208
- //#region src/config/config.ts
207
+ //#region src/config/config/config.ts
209
208
  var ConfigError = class extends Error {
210
209
  name = "ConfigError";
211
210
  };
@@ -314,7 +313,64 @@ function resolveConfig(raw) {
314
313
  };
315
314
  }
316
315
  //#endregion
317
- //#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
318
374
  /**
319
375
  * Command → context. Keys are the full command path as it lands in
320
376
  * yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
@@ -375,183 +431,556 @@ function commandsInContext(context) {
375
431
  return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
376
432
  }
377
433
  //#endregion
378
- //#region src/ui/style.ts
379
- const style = {
380
- success: (msg) => `${chalk.green("✓")} ${msg}`,
381
- warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
382
- fail: (msg) => `${chalk.red("✗")} ${msg}`,
383
- step: (msg) => `${chalk.cyan("→")} ${msg}`,
384
- /** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
385
- skip: (msg) => chalk.dim(`· ${msg}`),
386
- hint: (msg) => chalk.dim(msg),
387
- dim: (msg) => chalk.dim(msg),
388
- header: (msg) => chalk.bold(msg)
389
- };
390
- //#endregion
391
- //#region src/logger.ts
392
- /**
393
- * Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
394
- * node`, …) — ADR-0012. These commands aren't "about" any repo, so their
395
- * logs must never depend on `$PWD` or which repo's `.envrc` happens to be
396
- * active; they always ship to holocron's own dataset instead of whatever
397
- * `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
398
- * `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
399
- * per-repo override.
400
- */
401
- const GLOBAL_AXIOM_DATASET = "holocron-global";
402
- /**
403
- * Resolve the explicit level to hand to `createLogger`, in priority order:
404
- *
405
- * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
406
- * 4. `HOLOCRON_LOG_LEVEL` env var
407
- * 5. `holocron.config` `log.level` (`configLevel`)
408
- *
409
- * Returns `undefined` when nothing applies — `createLogger` then defaults
410
- * to `"info"`. Resolving the full chain here (rather than passing
411
- * `configLevel` straight through) keeps config below the env var.
412
- */
413
- function resolveLogLevel(argv, configLevel) {
414
- if (argv.log) return argv.log;
415
- if (argv.verbose) return "debug";
416
- if (argv.quiet) return "error";
417
- return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
418
- }
434
+ //#region src/ui/non-interactive-error/non-interactive-error.ts
419
435
  /**
420
- * Resolve whether human-visible console output (pretty or NDJSON) should run
421
- * at all, independent of level — the `@theholocron/observability`
422
- * `consoleOutput` override.
423
- *
424
- * Local/interactive runs are quiet by default: any raw structured logger
425
- * output (colorized or not) stays off regardless of the resolved level,
426
- * unless the user explicitly asked to see it via `--log <level>`,
427
- * `--verbose`, or `--quiet`. CI is untouched either way — returning
428
- * `undefined` there preserves `createLogger`'s own `ci`/`tty`
429
- * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
430
- * 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.
431
440
  */
432
- function resolveConsoleOutput(argv) {
433
- if (argv.log || argv.verbose || argv.quiet) return true;
434
- 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;
435
448
  }
436
- let root;
437
- let rootLevel;
438
- let rootCommand;
439
- let rootAxiomKey;
440
- /**
441
- * Resolve Axiom credentials for the CLI.
442
- *
443
- * A `global`-context command (ADR-0012) always resolves to
444
- * {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
445
- * `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
446
- * isn't "about" any repo, so the active directory's dataset is never the
447
- * right answer. Everything else keeps the existing chain: env vars win
448
- * (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
449
- * falling back to the CLI-only bridge that pairs the OS-keyring token
450
- * (`axiom.<org>` then bare `axiom`) with a dataset from
451
- * `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
452
- * Returns `undefined` unless both a token and a dataset are found.
453
- */
454
- function resolveCliAxiom(opts) {
455
- const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
456
- if (!isGlobal) {
457
- const fromEnv = resolveAxiomFromEnv();
458
- if (fromEnv) return fromEnv;
459
- }
460
- const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : holocronEnv.get("AXIOM_DATASET") ?? opts.configAxiomDataset;
461
- if (!dataset) return void 0;
462
- const org = opts.org ?? env.get("HOLOCRON_ORG");
463
- const token = holocronEnv.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
464
- return token ? {
465
- dataset,
466
- token
467
- } : void 0;
449
+ function childrenOf(value) {
450
+ return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
468
451
  }
469
- /**
470
- * The process-wide root logger. Built once (from `cli.ts`'s middleware,
471
- * with the command name + flags + env). Rebuilt at most once more when a
472
- * command's handler supplies `holocron.config` context the flag/env-only
473
- * first pass could not have known — `log.level` (unless `--verbose` /
474
- * `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
475
- * for a keyring-backed Axiom transport. That rebuild generates a fresh
476
- * `runId`, which is harmless: nothing logs between the middleware and the
477
- * handler.
478
- */
479
- function buildCliLogger(argv, opts = {}) {
480
- const { command, configLevel } = opts;
481
- if (command) rootCommand = command;
482
- const level = resolveLogLevel(argv, configLevel);
483
- const consoleOutput = resolveConsoleOutput(argv);
484
- const axiom = resolveCliAxiom(opts);
485
- const axiomKey = axiom?.dataset;
486
- const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
487
- if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
488
- const built = createLogger({
489
- ...level ? { level } : {},
490
- ...axiom ? { axiom } : {},
491
- ...consoleOutput !== void 0 ? { consoleOutput } : {}
492
- });
493
- root = {
494
- logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
495
- runId: built.runId
496
- };
497
- rootLevel = level;
498
- rootAxiomKey = axiomKey;
499
- }
500
- 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));
501
457
  }
502
- /**
503
- * Lazily-memoized `Logger` for module-level call sites with no `argv` in
504
- * scope — notably yargs' own `.fail()` handler, which fires on a
505
- * pre-middleware validation failure (a missing required argument, an
506
- * unknown command) and therefore runs before `buildCliLogger` has ever
507
- * built the real root. Without an explicit `consoleOutput` here, that
508
- * fallback would use `createLogger`'s bare auto-detection (pretty on any
509
- * local TTY) and leak a structured block even though no flag asked for one
510
- * — so it resolves the same "quiet unless CI" default `buildCliLogger`
511
- * would, as if no flags were passed.
512
- */
513
- function getLogger() {
514
- 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}}`);
515
462
  }
516
463
  /**
517
- * Report a fatal, command-ending CLI error: always printed to the console
518
- * (regardless of `consoleOutput` suppression) via `style.fail`, and also
519
- * sent through the structured logger for Axiom/telemetry visibility.
520
- *
521
- * Use this when a handler is returning or exiting right here with nothing
522
- * else for the user to see — a resolved `--token` failure, a yargs
523
- * validation failure, a thrown domain error. Don't use it for a per-step
524
- * warning inside a multi-step orchestrator that already prints its own
525
- * `ok / fail / skip` summary — that summary is the user-facing signal;
526
- * 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.
527
466
  */
528
- function reportError(message, log = getLogger()) {
529
- console.error(style.fail(message));
530
- log.error(message);
531
- }
532
- /** The current root logger's correlation id, if a root has been built. */
533
- function getRunId() {
534
- 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;
535
476
  }
536
- //#endregion
537
- //#region src/plugin/loader.ts
538
- /**
539
- * `PluginLoader` — loads provider plugins per the resolved config and
540
- * builds a typed capability registry the runtime can query.
541
- *
542
- * Flow:
543
- * 1. Walk `config.providers[*]` from `resolveConfig()`
544
- * 2. For each entry, dynamic-import the resolved package name
545
- * (`@theholocron/holocron-plugin-<provider>` by default)
546
- * 3. Call the package's exported `createPlugin(options)` to get a
547
- * plugin object whose `capabilities` map holds factories
548
- * 4. Invoke the matching capability factory and stash the impl in
549
- * the registry — single-cardinality entries hold one impl,
550
- * many-cardinality entries hold an array
551
- *
552
- * A package may instead export a capability config (see
553
- * `CapabilityConfigPackage` in config.ts). The loader detects the shape
554
- * 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
555
984
  * preset options with any per-project overrides from the config file
556
985
  * (project options win, mirroring ESLint's `extends` precedence).
557
986
  *
@@ -699,7 +1128,111 @@ function isCapabilityConfigModule(mod) {
699
1128
  return typeof mod.default?.provider === "string";
700
1129
  }
701
1130
  //#endregion
702
- //#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
703
1236
  /**
704
1237
  * The `workspace`-context guard.
705
1238
  *
@@ -769,25 +1302,7 @@ function assertPluginsResolvable(loader, command) {
769
1302
  throw new WorkspaceContextError(command, [...new Set(failures.map((f) => f.packageName))]);
770
1303
  }
771
1304
  //#endregion
772
- //#region src/ui/progress.ts
773
- /**
774
- * Runs `fn`, showing an ora spinner for its duration in TTY environments.
775
- * In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
776
- */
777
- async function withSpinner(label, fn) {
778
- if (!process.stdout.isTTY) return fn();
779
- const spinner = ora(label).start();
780
- try {
781
- const result = await fn();
782
- spinner.succeed();
783
- return result;
784
- } catch (err) {
785
- spinner.fail();
786
- throw err;
787
- }
788
- }
789
- //#endregion
790
- //#region src/commands/auth.ts
1305
+ //#region src/commands/auth/auth.ts
791
1306
  /**
792
1307
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
793
1308
  * OS keyring.
@@ -1068,7 +1583,7 @@ async function tryLoadHint(importer, packageName) {
1068
1583
  }
1069
1584
  }
1070
1585
  //#endregion
1071
- //#region src/commands/cleanup-preview.ts
1586
+ //#region src/commands/cleanup-preview/cleanup-preview.ts
1072
1587
  function prStateLabel(pr) {
1073
1588
  if (pr.merged) return style.success("merged");
1074
1589
  if (pr.state === "closed") return style.dim("closed (not merged)");
@@ -1208,28 +1723,7 @@ async function runCleanupPreview(input) {
1208
1723
  }
1209
1724
  }
1210
1725
  //#endregion
1211
- //#region src/ui/status-row.ts
1212
- /** Right-pads `s` to `width` with spaces — never truncates. */
1213
- function pad(s, width) {
1214
- return s.length >= width ? s : s + " ".repeat(width - s.length);
1215
- }
1216
- /**
1217
- * One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
1218
- * then `columns` joined with a two-space gap (blank columns dropped, so a
1219
- * row that has nothing for its last column doesn't trail whitespace).
1220
- * Column padding (`pad`) is the caller's job — `doctor` pads capability and
1221
- * provider to a fixed width; `clone`'s repo names and paths don't, since
1222
- * they vary too widely for fixed-width alignment to read cleanly.
1223
- *
1224
- * Shared by `doctor` (capability / provider / message) and `clone` (repo /
1225
- * destination / reason) so every status table in the CLI reads the same way.
1226
- */
1227
- function statusRow(status, columns) {
1228
- const joined = columns.filter((c) => c.length > 0).join(" ");
1229
- return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
1230
- }
1231
- //#endregion
1232
- //#region src/commands/clone.ts
1726
+ //#region src/commands/clone/clone.ts
1233
1727
  /**
1234
1728
  * Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
1235
1729
  * `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
@@ -1426,7 +1920,7 @@ async function runClone(input) {
1426
1920
  };
1427
1921
  }
1428
1922
  //#endregion
1429
- //#region src/commands/deploy.ts
1923
+ //#region src/commands/deploy/deploy.ts
1430
1924
  /**
1431
1925
  * `holocron deploy` — trigger a deployment via the configured
1432
1926
  * `deployment` capability.
@@ -1666,7 +2160,7 @@ async function runDeployFromFiles(input) {
1666
2160
  }
1667
2161
  }
1668
2162
  //#endregion
1669
- //#region src/commands/doctor.ts
2163
+ //#region src/commands/doctor/doctor.ts
1670
2164
  /**
1671
2165
  * `holocron doctor` — load the config, instantiate every capability,
1672
2166
  * print a per-capability readiness report.
@@ -1812,117 +2306,13 @@ function countResolvedLifecycle(report) {
1812
2306
  function mk(capability, provider, status, message) {
1813
2307
  return {
1814
2308
  capability,
1815
- provider,
1816
- status,
1817
- message
1818
- };
1819
- }
1820
- //#endregion
1821
- //#region src/plugin/run-check.ts
1822
- async function runCheckAgainstUrl(input, opts = {}) {
1823
- const doFetch = opts.fetch ?? fetch;
1824
- const url = resolveUrl(input.url, opts.url);
1825
- const method = input.method ?? "GET";
1826
- const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
1827
- const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
1828
- const start = Date.now();
1829
- let response;
1830
- let bodyText = "";
1831
- try {
1832
- response = await doFetch(url, {
1833
- method,
1834
- ...hasBody ? { body: input.body } : {},
1835
- redirect: "manual",
1836
- signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
1837
- });
1838
- if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
1839
- } catch (err) {
1840
- return {
1841
- passed: false,
1842
- failures: [],
1843
- requestError: requestErrorMessage(err)
1844
- };
1845
- }
1846
- const responseTimeMs = Date.now() - start;
1847
- const failures = [];
1848
- if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
1849
- for (const assertion of input.assertions ?? []) {
1850
- const actual = extractActual(assertion, {
1851
- response,
1852
- bodyText,
1853
- responseTimeMs
1854
- });
1855
- if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
1856
- }
1857
- return {
1858
- passed: failures.length === 0,
1859
- status: response.status,
1860
- responseTimeMs,
1861
- failures
1862
- };
1863
- }
1864
- /** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
1865
- function resolveUrl(originalUrl, overrideUrl) {
1866
- if (!overrideUrl) return originalUrl;
1867
- const original = new URL(originalUrl);
1868
- const resolved = new URL(overrideUrl);
1869
- resolved.pathname = original.pathname;
1870
- resolved.search = original.search;
1871
- return resolved.toString();
1872
- }
1873
- function requestErrorMessage(err) {
1874
- if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
1875
- return String(err);
1876
- }
1877
- /** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
1878
- async function readBodyCapped(response, maxBytes) {
1879
- if (!response.body) return "";
1880
- const reader = response.body.getReader();
1881
- const chunks = [];
1882
- let total = 0;
1883
- try {
1884
- for (;;) {
1885
- const { done, value } = await reader.read();
1886
- if (done) break;
1887
- const remaining = maxBytes - total;
1888
- if (remaining <= 0) break;
1889
- const stored = value.length > remaining ? value.slice(0, remaining) : value;
1890
- chunks.push(stored);
1891
- total += stored.length;
1892
- }
1893
- } finally {
1894
- await reader.cancel().catch(() => {});
1895
- }
1896
- return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
1897
- }
1898
- /** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
1899
- function truncate(value, max = 200) {
1900
- return value.length > max ? `${value.slice(0, max)}…` : value;
1901
- }
1902
- function extractActual(assertion, ctx) {
1903
- switch (assertion.source) {
1904
- case "status_code": return String(ctx.response.status);
1905
- case "response_time": return String(ctx.responseTimeMs);
1906
- case "body": return ctx.bodyText;
1907
- case "header": {
1908
- const name = assertion.property ?? "";
1909
- return name ? ctx.response.headers.get(name) ?? "" : "";
1910
- }
1911
- }
1912
- }
1913
- function evaluate(comparison, actual, target) {
1914
- const t = target ?? "";
1915
- switch (comparison) {
1916
- case "equals": return actual === t;
1917
- case "not_equals": return actual !== t;
1918
- case "contains": return actual.includes(t);
1919
- case "not_contains": return !actual.includes(t);
1920
- case "greater_than": return Number(actual) > Number(t);
1921
- case "less_than": return Number(actual) < Number(t);
1922
- }
2309
+ provider,
2310
+ status,
2311
+ message
2312
+ };
1923
2313
  }
1924
2314
  //#endregion
1925
- //#region src/commands/monitoring-check.ts
2315
+ //#region src/commands/monitoring-check/monitoring-check.ts
1926
2316
  async function runMonitoringCheck(input) {
1927
2317
  const print = input.print ?? ((line) => console.log(line));
1928
2318
  const logger = input.logger ?? getLogger();
@@ -1983,7 +2373,7 @@ async function runMonitoringCheck(input) {
1983
2373
  };
1984
2374
  }
1985
2375
  //#endregion
1986
- //#region src/commands/new.ts
2376
+ //#region src/commands/new/new.ts
1987
2377
  /**
1988
2378
  * `holocron new repo <type> <name>` — create a GitHub repo from a template and
1989
2379
  * bootstrap it by replacing all template-slug casing variants with the new
@@ -2377,7 +2767,7 @@ async function runNew(input) {
2377
2767
  };
2378
2768
  }
2379
2769
  //#endregion
2380
- //#region src/commands/npm-bump-versions.ts
2770
+ //#region src/commands/npm-bump-versions/npm-bump-versions.ts
2381
2771
  async function runNpmBumpVersions(input) {
2382
2772
  const print = input.print ?? ((line) => console.log(line));
2383
2773
  const logger = input.logger ?? getLogger();
@@ -2458,9 +2848,9 @@ async function runNpmBumpVersions(input) {
2458
2848
  };
2459
2849
  }
2460
2850
  //#endregion
2461
- //#region src/commands/plugin-create/template-inputs.ts
2851
+ //#region src/commands/plugin-create/template-inputs/template-inputs.ts
2462
2852
  /** Derive the standard defaults from a slug + vendor name. */
2463
- function deriveDefaults$1(input) {
2853
+ function deriveDefaults(input) {
2464
2854
  const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
2465
2855
  const capability = input.capability;
2466
2856
  return {
@@ -3399,7 +3789,7 @@ function runPluginCreate(input) {
3399
3789
  const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
3400
3790
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
3401
3791
  validateCapability(input.capability, print);
3402
- const derived = deriveDefaults$1({
3792
+ const derived = deriveDefaults({
3403
3793
  slug: input.slug,
3404
3794
  vendorName: input.vendorName,
3405
3795
  capability: input.capability
@@ -3555,224 +3945,142 @@ function printNextSteps$1(print, inputs) {
3555
3945
  print(" 7. Commit + push when capability is functionally complete.");
3556
3946
  }
3557
3947
  //#endregion
3558
- //#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
3559
3967
  /**
3560
- * `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
3561
- * that every new npm-published holocron monorepo hits.
3562
- *
3563
- * npm requires a package to exist before Trusted Publishing can be
3564
- * configured on it. So the first publish has to happen outside the OIDC
3565
- * flow — a browser-auth session (`npm login --auth-type=web`) or an
3566
- * ephemeral automation token. This command drives the login step itself
3567
- * (when `npm whoami` shows you're not authenticated), runs the publish
3568
- * step, and tells you exactly what to do next.
3569
- *
3570
- * Workflow:
3571
- *
3572
- * $ pnpm install --frozen-lockfile
3573
- * $ pnpm build
3574
- * $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
3575
- *
3576
- * `npm login --auth-type=web` runs automatically the first time — no
3577
- * separate manual step. `pnpm install` + `pnpm build` stay outside the
3578
- * command (no pnpm-inside-pnpm).
3579
- *
3580
- * `--initial` is required today — this command only implements the
3581
- * bootstrap publish. A non-initial `holocron package publish` (for a manual publish
3582
- * outside the semantic-release/OIDC steady state) isn't built yet; the flag
3583
- * exists so the surface doesn't need another rename when it is.
3584
- *
3585
- * If `NPM_TOKEN` is detected in env, the command prints a final
3586
- * "revoke this token at <url>" reminder — same pattern as `rando vc
3587
- * 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).
3588
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
3589
3992
  /**
3590
- * `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
3591
- * Trusted Publisher (OIDC) config to a new workflow filename across every
3592
- * public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
3593
- * shell-script prototype this supersedes) for the full "why": npm ties
3594
- * Trusted Publisher to a workflow FILENAME per package, so renaming the
3595
- * release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
3596
- * config is updated to match.
3597
- *
3598
- * Requires npm@11.15.0+ (`npm trust`) — always shells out through
3599
- * `npx -y npm@latest` regardless of the locally-installed npm version.
3600
- *
3601
- * npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
3602
- * require a fresh interactive browser 2FA approval (an `EOTP` error with an
3603
- * `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
3604
- * with the bypass-2FA option are explicitly unsupported for trust commands).
3605
- * This command can't drive that browser step itself, so on the first `EOTP`
3606
- * it stops immediately, prints the `authUrl`, and returns — the operator
3607
- * approves it and re-runs; already-migrated packages are skipped on the next
3608
- * 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.
3609
3996
  */
3610
- async function runSyncTrust(input) {
3611
- const print = input.print ?? ((line) => console.log(line));
3612
- const logger = input.logger ?? getLogger();
3613
- const cwd = input.cwd ?? process.cwd();
3614
- const repo = input.repo ?? "theholocron/holocron";
3615
- const dryRun = input.dryRun ?? false;
3616
- const exec = input.exec ?? defaultExec$1;
3617
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
3618
- const npm = "npx";
3619
- const npmArgs = ["-y", "npm@latest"];
3620
- print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
3621
- print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
3622
- print(` ${packageNames.length} public packages`);
3623
- print("");
3624
- logger.info({
3625
- oldFile: input.oldFile,
3626
- newFile: input.newFile,
3627
- packages: packageNames.length
3628
- }, "sync-trust: start");
3629
- if (packageNames.length === 0) {
3630
- const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
3631
- print(` ✗ ${message}`);
3632
- return {
3633
- status: "fail",
3634
- message,
3635
- packages: []
3636
- };
3637
- }
3638
- if (dryRun) {
3639
- for (const name of packageNames) print(` … would sync ${name}`);
3640
- return {
3641
- status: "dry-run",
3642
- message: "dry-run — no npm calls made",
3643
- packages: []
3644
- };
3645
- }
3646
- const results = [];
3647
- for (const name of packageNames) {
3648
- print(`=== ${name} ===`);
3649
- const parsed = parseJson((await exec(npm, [
3650
- ...npmArgs,
3651
- "trust",
3652
- "list",
3653
- name,
3654
- "--json"
3655
- ], { cwd })).stdout);
3656
- if (isEotp(parsed)) {
3657
- const authUrl = parsed.error?.authUrl ?? "";
3658
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3659
- print(" then re-run; already-migrated packages are skipped.");
3660
- results.push({
3661
- name,
3662
- status: "needs-auth",
3663
- message: authUrl
3664
- });
3665
- logger.warn({
3666
- package: name,
3667
- authUrl
3668
- }, "sync-trust: needs auth, stopping");
3669
- return {
3670
- status: "fail",
3671
- message: "npm 2FA grant needed — see printed authUrl",
3672
- packages: results
3673
- };
3674
- }
3675
- const entry = parsed;
3676
- if (entry.file === input.newFile) {
3677
- print(` already on ${input.newFile}, skipping`);
3678
- results.push({
3679
- name,
3680
- status: "skipped"
3681
- });
3682
- continue;
3683
- }
3684
- if (entry.id) {
3685
- print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
3686
- const revoke = await exec(npm, [
3687
- ...npmArgs,
3688
- "trust",
3689
- "revoke",
3690
- name,
3691
- "--id",
3692
- entry.id,
3693
- "-y"
3694
- ], { cwd });
3695
- if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
3696
- const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
3697
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3698
- results.push({
3699
- name,
3700
- status: "needs-auth",
3701
- message: authUrl
3702
- });
3703
- return {
3704
- status: "fail",
3705
- message: "npm 2FA grant needed — see printed authUrl",
3706
- packages: results
3707
- };
3708
- }
3709
- } else print(" no existing trust config found");
3710
- print(` creating trust: ${input.newFile}`);
3711
- const create = await exec(npm, [
3712
- ...npmArgs,
3713
- "trust",
3714
- "github",
3715
- name,
3716
- "--repo",
3717
- repo,
3718
- "--file",
3719
- input.newFile,
3720
- "--allow-publish",
3721
- "--allow-stage-publish",
3722
- "-y"
3723
- ], { cwd });
3724
- if (create.exitCode === 0) {
3725
- print(" ✓ ok");
3726
- results.push({
3727
- name,
3728
- status: "ok"
3729
- });
3730
- } else {
3731
- const createParsed = parseJson(create.stdout);
3732
- if (isEotp(createParsed)) {
3733
- const authUrl = createParsed.error?.authUrl ?? "";
3734
- print(` needs fresh 2FA — open and approve: ${authUrl}`);
3735
- results.push({
3736
- name,
3737
- status: "needs-auth",
3738
- message: authUrl
3739
- });
3740
- return {
3741
- status: "fail",
3742
- message: "npm 2FA grant needed — see printed authUrl",
3743
- packages: results
3744
- };
3745
- }
3746
- const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
3747
- print(` ✗ FAILED: ${message}`);
3748
- results.push({
3749
- name,
3750
- status: "fail",
3751
- message
3752
- });
3753
- }
3997
+ function discoverManifests(cwd) {
3998
+ if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
3999
+ const manifest = readPackageJsonSync(cwd);
4000
+ return manifest ? [{
4001
+ manifest,
4002
+ dir: "."
4003
+ }] : [];
3754
4004
  }
3755
- const failed = results.filter((r) => r.status === "fail");
3756
- logger.info({
3757
- ok: results.filter((r) => r.status === "ok").length,
3758
- failed: failed.length
3759
- }, "sync-trust: done");
3760
- return {
3761
- status: failed.length > 0 ? "fail" : "ok",
3762
- ...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
3763
- packages: results
3764
- };
4005
+ return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
4006
+ manifest,
4007
+ dir: `./${relativeDir}`
4008
+ }));
3765
4009
  }
3766
- function parseJson(raw) {
3767
- try {
3768
- return JSON.parse(raw);
3769
- } catch {
3770
- 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");
3771
4027
  }
3772
4028
  }
3773
- function isEotp(parsed) {
3774
- 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] : [];
3775
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
+ */
3776
4084
  async function runPublish(input = {}) {
3777
4085
  const print = input.print ?? ((line) => console.log(line));
3778
4086
  const logger = input.logger ?? getLogger();
@@ -3893,6 +4201,23 @@ async function runPublish(input = {}) {
3893
4201
  packageNames
3894
4202
  };
3895
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
3896
4221
  /**
3897
4222
  * `holocron package publish` (no `--initial`) — the steady-state publish
3898
4223
  * `exec.publishCmd` in `release.config.ts` should call, replacing each
@@ -4036,129 +4361,222 @@ async function runSteadyPublish(input = {}) {
4036
4361
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
4037
4362
  print(` pnpm exec holocron package publish --otp <6-digit-code>`);
4038
4363
  }
4039
- logger.warn({
4040
- tag,
4041
- reason: message
4042
- }, "publish: done");
4043
- return {
4044
- status: "fail",
4045
- message,
4046
- packageNames
4047
- };
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
+ }
4048
4566
  }
4049
- print(" ✓ publish complete");
4567
+ const failed = results.filter((r) => r.status === "fail");
4050
4568
  logger.info({
4051
- tag,
4052
- status: "ok",
4053
- packages: entries.length
4054
- }, "publish: done");
4569
+ ok: results.filter((r) => r.status === "ok").length,
4570
+ failed: failed.length
4571
+ }, "sync-trust: done");
4055
4572
  return {
4056
- status: "ok",
4057
- packageNames
4573
+ status: failed.length > 0 ? "fail" : "ok",
4574
+ ...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
4575
+ packages: results
4058
4576
  };
4059
4577
  }
4060
- function hasPackagesDir(cwd) {
4061
- return existsSync(join(cwd, "packages"));
4062
- }
4063
- /** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
4064
- function hasPluginsDir(cwd) {
4065
- return existsSync(join(cwd, "plugins"));
4066
- }
4067
- /**
4068
- * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4069
- * roots exist. Empty (single-package repo, no `-r`) when neither does.
4070
- * Shared by every `pnpm publish` invocation below so a repo with only
4071
- * `plugins/*` (or both) is covered the same as `packages/*`-only.
4072
- */
4073
- function workspaceFilterArgs(cwd) {
4074
- const filters = [];
4075
- if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4076
- if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4077
- return filters.length > 0 ? ["-r", ...filters] : [];
4078
- }
4079
- /**
4080
- * Monorepo (`packages/` and/or `plugins/` present): every workspace under
4081
- * either. Single package (neither dir present): the root `package.json`.
4082
- * Callers keep the public ones.
4083
- */
4084
- function discoverManifests(cwd) {
4085
- if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
4086
- const manifest = readPackageJsonSync(cwd);
4087
- return manifest ? [{
4088
- manifest,
4089
- dir: "."
4090
- }] : [];
4091
- }
4092
- return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
4093
- manifest,
4094
- dir: `./${relativeDir}`
4095
- }));
4096
- }
4097
- /**
4098
- * Every public package (`!private && name`) with its version and a
4099
- * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4100
- * mode needs both to check `npm view <pkg>@<version>` and publish one
4101
- * workspace at a time.
4102
- */
4103
- function discoverPublicPackageEntries(cwd) {
4104
- return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4105
- name: manifest.name,
4106
- version: manifest.version,
4107
- dir
4108
- }] : []);
4109
- }
4110
- /** The names of {@link discoverPublicPackageEntries}' packages, without requiring a version. */
4111
- function discoverPublicPackages(cwd) {
4112
- return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
4113
- }
4114
- async function resolveRepoName(cwd, exec) {
4115
- return (await readGitRemote(cwd, { exec: async (args, options) => {
4116
- const result = await exec("git", args, options);
4117
- if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4118
- return result.stdout;
4119
- } }))?.repo ?? "unknown";
4120
- }
4121
- function printNextSteps(print, env, packageNames, repoName) {
4122
- print("");
4123
- print(" → next: configure Trusted Publisher for each package on npm:");
4124
- for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
4125
- print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
4126
- if (env.get("NPM_TOKEN")) {
4127
- print("");
4128
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
4129
- print(" https://www.npmjs.com/settings/~/tokens");
4130
- }
4131
- }
4132
- const defaultExec$1 = async (cmd, args, opts) => {
4133
- const result = spawnSync(cmd, args, {
4134
- cwd: opts.cwd,
4135
- encoding: "utf8",
4136
- stdio: [
4137
- "inherit",
4138
- "pipe",
4139
- "pipe"
4140
- ]
4141
- });
4142
- return {
4143
- exitCode: result.status ?? -1,
4144
- stdout: result.stdout ?? "",
4145
- stderr: result.stderr ?? ""
4146
- };
4147
- };
4148
- /**
4149
- * Fully interactive — `npm login --auth-type=web` prints a URL and waits for
4150
- * the browser flow to complete; the operator needs to see that live, so all
4151
- * three stdio streams are inherited (unlike `defaultExec`, which pipes
4152
- * stdout/stderr for capture).
4153
- */
4154
- const defaultLogin = async (cwd) => {
4155
- return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
4156
- cwd,
4157
- stdio: "inherit"
4158
- }).status ?? -1 };
4159
- };
4160
4578
  //#endregion
4161
- //#region src/commands/secret-set.ts
4579
+ //#region src/commands/secret-set/secret-set.ts
4162
4580
  /**
4163
4581
  * `holocron secrets set` — one-shot single-secret push.
4164
4582
  *
@@ -4250,7 +4668,7 @@ function describeScope(scope) {
4250
4668
  return `scope=organization:${scope.name}`;
4251
4669
  }
4252
4670
  //#endregion
4253
- //#region src/commands/secrets-sync.ts
4671
+ //#region src/commands/secrets-sync/secrets-sync.ts
4254
4672
  /**
4255
4673
  * `holocron secrets sync` — vault → destinations.
4256
4674
  *
@@ -4415,7 +4833,7 @@ function vaultProviderName(loader) {
4415
4833
  return loader.get("vault").providerName;
4416
4834
  }
4417
4835
  //#endregion
4418
- //#region src/commands/setup/derived-properties.ts
4836
+ //#region src/commands/setup/derived-properties/derived-properties.ts
4419
4837
  /**
4420
4838
  * Repo archetype, in priority order:
4421
4839
  * 1. `*-template` repo name → `template` (independent of package shape).
@@ -4609,221 +5027,9 @@ const STALE_LABELS = [
4609
5027
  "released on @alpha"
4610
5028
  ];
4611
5029
  //#endregion
4612
- //#region src/templates/config.yml
4613
- 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";
4614
- //#endregion
4615
- //#region src/utils/create-header.ts
4616
- function createHeader(options) {
4617
- const { source, tool = "holocron repo setup", forPrimary = false } = options;
4618
- const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
4619
- return {
4620
- workflowHeader(format = "yaml") {
4621
- if (format === "cjs") return [
4622
- `/* ${doNotEdit}`,
4623
- ` * Source: theholocron/holocron · ${source}`,
4624
- ` * Tool: ${tool}`,
4625
- ` * Changes: edit source in theholocron/holocron`,
4626
- ` */`,
4627
- ``
4628
- ].join("\n");
4629
- const yamlLines = [
4630
- `# ${doNotEdit}`,
4631
- `# Source: theholocron/holocron · ${source}`,
4632
- `# Tool: ${tool}`,
4633
- `# Changes: edit source in theholocron/holocron`,
4634
- ``
4635
- ].join("\n");
4636
- if (format === "shebang") return `#!/bin/sh\n\n${yamlLines}`;
4637
- return yamlLines;
4638
- },
4639
- scaffoldHeader() {
4640
- return [
4641
- `# Scaffolded by holocron repo setup — edit this file freely.`,
4642
- `# Source: theholocron/holocron · ${source}`,
4643
- ``
4644
- ].join("\n");
4645
- }
4646
- };
4647
- }
4648
- //#endregion
4649
- //#region src/templates/configs/alexjs/alexignore
4650
- var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
4651
- //#endregion
4652
- //#region src/templates/configs/alexjs/alexrc.json
4653
- var alexrc_default = { allow: [
4654
- "dead",
4655
- "execute",
4656
- "execution",
4657
- "executes",
4658
- "failure",
4659
- "failures",
4660
- "hook",
4661
- "hooks",
4662
- "husky",
4663
- "period"
4664
- ] };
4665
- //#endregion
4666
- //#region src/templates/configs/alexjs/create-config.ts
4667
- const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4668
- function createRcConfig() {
4669
- return stringifyJson(alexrc_default);
4670
- }
4671
- function createIgnoreConfig$1() {
4672
- return `${workflowHeader$9()}${alexignore_default}`;
4673
- }
4674
- //#endregion
4675
- //#region src/templates/configs/commit-msg/commit-msg
4676
- var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
4677
- //#endregion
4678
- //#region src/templates/configs/commit-msg/create-config.ts
4679
- const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4680
- /**
4681
- * `.husky/commit-msg` — lints the commit message via `holocron run lint
4682
- * commit-msg` (holocron#789), the real programmatic replacement for
4683
- * shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
4684
- * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4685
- *
4686
- * @param holocronScript - the command that invokes the CLI. Defaults to
4687
- * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4688
- */
4689
- function createConfig$7(holocronScript = "pnpm exec holocron") {
4690
- return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4691
- }
4692
- //#endregion
4693
- //#region src/templates/configs/devmoji/create-config.ts
4694
- const { workflowHeader: workflowHeader$7 } = createHeader({ source: "packages/cli/src/templates/configs/devmoji/create-config.ts" });
4695
- function createConfig$6() {
4696
- return [
4697
- workflowHeader$7("cjs"),
4698
- `/* eslint-disable */`,
4699
- `const { defineConfig } = require("@theholocron/devmoji-config");`,
4700
- `module.exports = defineConfig();`,
4701
- ``
4702
- ].join("\n");
4703
- }
4704
- //#endregion
4705
- //#region src/templates/configs/editorconfig/editorconfig
4706
- 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";
4707
- //#endregion
4708
- //#region src/templates/configs/editorconfig/create-config.ts
4709
- const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
4710
- function createConfig$5() {
4711
- return `${workflowHeader$6()}${editorconfig_default}`;
4712
- }
4713
- //#endregion
4714
- //#region src/templates/configs/editorconfig-checker/editorconfig-checker.json
4715
- var editorconfig_checker_default = {
4716
- Verbose: false,
4717
- Format: "",
4718
- Debug: false,
4719
- IgnoreDefaults: false,
4720
- SpacesAfterTabs: false,
4721
- NoColor: false,
4722
- Exclude: [
4723
- "(^|.+/)LICENSE$",
4724
- "^public/.*",
4725
- "\\.md$",
4726
- "\\.mdx$"
4727
- ],
4728
- AllowedContentTypes: [],
4729
- PassedFiles: [],
4730
- Disable: {
4731
- "EndOfLine": false,
4732
- "Indentation": false,
4733
- "InsertFinalNewline": false,
4734
- "TrimTrailingWhitespace": false,
4735
- "IndentSize": false,
4736
- "MaxLineLength": false
4737
- }
4738
- };
4739
- //#endregion
4740
- //#region src/templates/configs/editorconfig-checker/create-config.ts
4741
- /**
4742
- * The generated `.editorconfig-checker.json`.
4743
- *
4744
- * Deliberately carries no `"Version"` key: editorconfig-checker treats it as a
4745
- * hard gate and exits non-zero the moment the running binary's version differs
4746
- * — which it does the instant a contributor's local install (Homebrew tracks
4747
- * latest) drifts from the version super-linter bundles in CI, breaking
4748
- * `holocron run lint` / the pre-push hook for everyone (#618). The config
4749
- * options here (`Exclude`, `Disable`) are stable across releases, so pinning
4750
- * buys nothing.
4751
- */
4752
- function createConfig$4() {
4753
- return stringifyJson(editorconfig_checker_default);
4754
- }
4755
- //#endregion
4756
- //#region src/templates/configs/pre-commit/pre-commit
4757
- 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";
4758
- //#endregion
4759
- //#region src/templates/configs/pre-commit/create-config.ts
4760
- const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
4761
- /**
4762
- * `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
4763
- * lint-staged before every commit. Installed by `holocron repo setup` when hooks
4764
- * are enabled (default for `protection: "strict"`). `git commit --no-verify`
4765
- * bypasses it.
4766
- *
4767
- * `gitleaks protect --staged` (not `gitleaks git`) is deliberate: the latter
4768
- * scans the entire commit history on every invocation, so once any false
4769
- * positive lands in a past commit — a fake test token, an example key in a
4770
- * doc — every future commit is permanently blocked, regardless of what's
4771
- * actually staged.
4772
- */
4773
- function createConfig$3() {
4774
- return `${workflowHeader$5("shebang")}${pre_commit_default}`;
4775
- }
4776
- //#endregion
4777
- //#region src/templates/configs/pre-push/pre-push
4778
- 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";
4779
- //#endregion
4780
- //#region src/templates/configs/pre-push/create-config.ts
4781
- const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
4782
- /**
4783
- * `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
4784
- * before every push. Installed by `holocron repo setup` when hooks are enabled
4785
- * (default for `protection: "strict"`). `git push --no-verify` bypasses it.
4786
- *
4787
- * @param holocronScript - the command that invokes the CLI. Defaults to
4788
- * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4789
- */
4790
- function createConfig$2(holocronScript = "pnpm exec holocron") {
4791
- return `${workflowHeader$4("shebang")}${pre_push_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
4792
- }
4793
- //#endregion
4794
- //#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
4795
- 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";
4796
- //#endregion
4797
- //#region src/templates/configs/prepare-commit-msg/create-config.ts
4798
- const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
4799
- function createConfig$1() {
4800
- return `${workflowHeader$3("shebang")}${prepare_commit_msg_default}`;
4801
- }
4802
- //#endregion
4803
- //#region src/templates/configs/yamllint/.yamlignore
4804
- var _yamlignore_default = "pnpm-lock.yaml\n";
4805
- //#endregion
4806
- //#region src/templates/configs/yamllint/.yamllint.yml
4807
- 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";
4808
- //#endregion
4809
- //#region src/templates/configs/yamllint/create-config.ts
4810
- const { workflowHeader: workflowHeader$2 } = createHeader({ source: "packages/cli/src/templates/configs/yamllint/create-config.ts" });
4811
- function createConfig() {
4812
- return `${workflowHeader$2()}${_yamllint_default}`;
4813
- }
4814
- function createIgnoreConfig() {
4815
- return `${workflowHeader$2()}${_yamlignore_default}`;
4816
- }
4817
- //#endregion
4818
5030
  //#region src/templates/dco.yml
4819
5031
  var dco_default = "allowRemediationCommits:\n individual: true\n";
4820
5032
  //#endregion
4821
- //#region src/templates/dependabot.yml
4822
- 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";
4823
- //#endregion
4824
- //#region src/templates/labeler.yml
4825
- 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";
4826
- //#endregion
4827
5033
  //#region src/commands/setup/agent-prompts-data.ts
4828
5034
  /**
4829
5035
  * Canonical AI engineering workflow role prompts.
@@ -4834,7 +5040,6 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
4834
5040
  *
4835
5041
  * Source: docs/wiki/specifications/ai-engineering-workflow.spec.md
4836
5042
  */
4837
- 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";
4838
5043
  const AGENT_PROMPTS = {
4839
5044
  "discovery.md": `# Discovery Agent
4840
5045
 
@@ -4963,10 +5168,14 @@ confidently, identify the missing information instead of inventing it.
4963
5168
 
4964
5169
  ## Format
4965
5170
 
4966
- 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.
4967
5176
 
4968
5177
  \`\`\`sh
4969
- 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
4970
5179
  \`\`\`
4971
5180
 
4972
5181
  Save to \`docs/wiki/decisions/NNNN-<slug>.md\`.
@@ -5173,10 +5382,13 @@ decision, the options that were considered, and the rationale for the choice mad
5173
5382
 
5174
5383
  ## Creating a new decision
5175
5384
 
5176
- 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:
5177
5389
 
5178
5390
  \`\`\`sh
5179
- 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
5180
5392
  \`\`\`
5181
5393
 
5182
5394
  \`madr\` has no CLI — creation is manual. Frontmatter is validated in CI
@@ -5358,7 +5570,6 @@ async function writeIfAbsent(filePath, content) {
5358
5570
  async function installEngineeringStructure({ repoRoot }) {
5359
5571
  const results = [];
5360
5572
  const writes = [
5361
- [join(repoRoot, "docs/wiki/decisions/template.md"), DECISIONS_TEMPLATE],
5362
5573
  [join(repoRoot, "docs/wiki/decisions/README.md"), DECISIONS_README],
5363
5574
  [join(repoRoot, "docs/wiki/standards/README.md"), STANDARDS_README],
5364
5575
  [join(repoRoot, "docs/wiki/specifications/README.md"), SPECIFICATIONS_README]
@@ -5381,7 +5592,7 @@ const BALANCED_REPO_SETTINGS = {
5381
5592
  has_wiki: false
5382
5593
  };
5383
5594
  //#endregion
5384
- //#region src/commands/setup/run-step.ts
5595
+ //#region src/commands/setup/run-step/run-step.ts
5385
5596
  async function runStep(capability, step, dryRun, body, opts = {}) {
5386
5597
  const result = await execStep(capability, step, dryRun, body, opts);
5387
5598
  const { status, message, reason } = result;
@@ -5452,7 +5663,7 @@ function formatStep(step) {
5452
5663
  return ` ${style.dim(`· ${label}`)}`;
5453
5664
  }
5454
5665
  //#endregion
5455
- //#region src/commands/setup/skills.ts
5666
+ //#region src/commands/setup/skills/skills.ts
5456
5667
  async function fetchExternalSkill(entry) {
5457
5668
  if (entry.sourceType !== "github")
5458
5669
  /* c8 ignore next */
@@ -5729,14 +5940,14 @@ async function runSetup(input) {
5729
5940
  if (loader.has("source") && (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
5730
5941
  const source = loader.get("source");
5731
5942
  steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
5732
- await source.writeRepoFile(".github/labeler.yml", `${workflowHeader$1()}${labeler_default}`);
5943
+ await source.writeRepoFile(".github/labeler.yml", createLabeler());
5733
5944
  }));
5734
5945
  print(formatStep(steps[steps.length - 1]));
5735
5946
  }
5736
5947
  if (loader.has("source") && effectivePreset !== "none") {
5737
5948
  const source = loader.get("source");
5738
5949
  steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
5739
- await source.writeRepoFile(".github/dependabot.yml", `${workflowHeader$1()}${dependabot_default}`);
5950
+ await source.writeRepoFile(".github/dependabot.yml", createDependabot());
5740
5951
  }));
5741
5952
  print(formatStep(steps[steps.length - 1]));
5742
5953
  steps.push(await runStep("source", "write .github/dco.yml", dryRun, async () => {
@@ -5746,54 +5957,50 @@ async function runSetup(input) {
5746
5957
  }
5747
5958
  if (loader.has("source")) {
5748
5959
  const source = loader.get("source");
5749
- steps.push(await runStep("source", "write .github/config.yml", dryRun, async () => {
5750
- await source.writeRepoFile(".github/config.yml", `${workflowHeader$1()}${config_default}`);
5751
- }));
5752
- print(formatStep(steps[steps.length - 1]));
5753
5960
  steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
5754
- await source.writeRepoFile(".alexrc.json", createRcConfig());
5961
+ await source.writeRepoFile(".alexrc.json", createAlexrc());
5755
5962
  }));
5756
5963
  print(formatStep(steps[steps.length - 1]));
5757
5964
  steps.push(await runStep("source", "write .alexignore", dryRun, async () => {
5758
- await source.writeRepoFile(".alexignore", createIgnoreConfig$1());
5965
+ await source.writeRepoFile(".alexignore", createAlexignore());
5759
5966
  }));
5760
5967
  print(formatStep(steps[steps.length - 1]));
5761
5968
  steps.push(await runStep("source", "write .yamllint.yml", dryRun, async () => {
5762
- await source.writeRepoFile(".yamllint.yml", createConfig());
5969
+ await source.writeRepoFile(".yamllint.yml", createYamllint());
5763
5970
  }));
5764
5971
  print(formatStep(steps[steps.length - 1]));
5765
5972
  steps.push(await runStep("source", "write .yamlignore", dryRun, async () => {
5766
- await source.writeRepoFile(".yamlignore", createIgnoreConfig());
5973
+ await source.writeRepoFile(".yamlignore", createYamlignore());
5767
5974
  }));
5768
5975
  print(formatStep(steps[steps.length - 1]));
5769
5976
  steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
5770
- await source.writeRepoFile(".editorconfig", createConfig$5());
5977
+ await source.writeRepoFile(".editorconfig", createEditorconfig());
5771
5978
  }));
5772
5979
  print(formatStep(steps[steps.length - 1]));
5773
5980
  steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
5774
- await source.writeRepoFile(".editorconfig-checker.json", createConfig$4());
5981
+ await source.writeRepoFile(".editorconfig-checker.json", createEditorconfigChecker());
5775
5982
  }));
5776
5983
  print(formatStep(steps[steps.length - 1]));
5777
5984
  steps.push(await runStep("source", "write devmoji.config.cjs", dryRun, async () => {
5778
- await source.writeRepoFile("devmoji.config.cjs", createConfig$6());
5985
+ await source.writeRepoFile("devmoji.config.cjs", createDevmoji());
5779
5986
  }));
5780
5987
  print(formatStep(steps[steps.length - 1]));
5781
5988
  steps.push(await runStep("source", "write .husky/prepare-commit-msg", dryRun, async () => {
5782
- await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
5989
+ await source.writeRepoFile(".husky/prepare-commit-msg", createPrepareCommitMsg());
5783
5990
  }));
5784
5991
  print(formatStep(steps[steps.length - 1]));
5785
5992
  steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5786
- await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5993
+ await source.writeRepoFile(".husky/commit-msg", createCommitMsg(config.holocronScript));
5787
5994
  }));
5788
5995
  print(formatStep(steps[steps.length - 1]));
5789
5996
  if (hooksEnabled) {
5790
5997
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5791
- await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
5998
+ await source.writeRepoFile(".husky/pre-push", createPrePush(config.holocronScript));
5792
5999
  return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
5793
6000
  }));
5794
6001
  print(formatStep(steps[steps.length - 1]));
5795
6002
  steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
5796
- await source.writeRepoFile(".husky/pre-commit", createConfig$3());
6003
+ await source.writeRepoFile(".husky/pre-commit", createPreCommit());
5797
6004
  return "runs GitLeaks (staged-only) + lint-staged before commit";
5798
6005
  }));
5799
6006
  print(formatStep(steps[steps.length - 1]));
@@ -6309,7 +6516,7 @@ async function ensurePrepareScript(repoRoot) {
6309
6516
  return true;
6310
6517
  }
6311
6518
  //#endregion
6312
- //#region src/commands/skills.ts
6519
+ //#region src/commands/skills/skills.ts
6313
6520
  /**
6314
6521
  * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
6315
6522
  *
@@ -6408,7 +6615,7 @@ function runSkillsUpdate(input) {
6408
6615
  return { status: exitCode === 0 ? "ok" : "fail" };
6409
6616
  }
6410
6617
  //#endregion
6411
- //#region src/commands/sync-readme.ts
6618
+ //#region src/commands/sync-readme/sync-readme.ts
6412
6619
  const MARKER_INSTALL_START = "<!-- holocron:installation -->";
6413
6620
  const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
6414
6621
  const REGISTRY_MAP = {
@@ -6564,7 +6771,7 @@ async function runSyncReadme(input) {
6564
6771
  };
6565
6772
  }
6566
6773
  //#endregion
6567
- //#region src/commands/sync-wiki.ts
6774
+ //#region src/commands/sync-wiki/sync-wiki.ts
6568
6775
  function resolveToken(input) {
6569
6776
  return input.token ?? input.context.cliToken ?? firstEnv(env, [
6570
6777
  "HOLOCRON_READ_TOKEN",
@@ -6781,7 +6988,7 @@ async function runSyncWiki(input) {
6781
6988
  }
6782
6989
  }
6783
6990
  //#endregion
6784
- //#region src/commands/sync.ts
6991
+ //#region src/commands/sync/sync.ts
6785
6992
  const { workflowHeader } = createHeader({
6786
6993
  source: "packages/astromech/src/thin-callers.ts",
6787
6994
  tool: "holocron repo sync"
@@ -7369,7 +7576,7 @@ async function writeWorkflowFile(repoRoot, filename, content) {
7369
7576
  await rename(tmpPath, join(dir, filename));
7370
7577
  }
7371
7578
  //#endregion
7372
- //#region src/telemetry/resolve.ts
7579
+ //#region src/observability/resolve-telemetry/resolve-telemetry.ts
7373
7580
  /**
7374
7581
  * Holocron-specific credential resolution for the telemetry sinks — the env
7375
7582
  * chain plus Holocron's own shipped fallbacks. This is **CLI policy**, not part
@@ -7404,7 +7611,7 @@ function resolvePostHogHost() {
7404
7611
  return holocronEnv.get("POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST;
7405
7612
  }
7406
7613
  //#endregion
7407
- //#region src/telemetry.ts
7614
+ //#region src/observability/telemetry/telemetry.ts
7408
7615
  /**
7409
7616
  * CLI self-telemetry orchestration — errors (Sentry), usage analytics (PostHog).
7410
7617
  *
@@ -7415,7 +7622,7 @@ function resolvePostHogHost() {
7415
7622
  * installed when telemetry is off. Same seam the package's `Logger` uses for
7416
7623
  * Pino.
7417
7624
  *
7418
- * Activation is env-var + shipped-fallback, resolved here (`telemetry/resolve.ts`)
7625
+ * Activation is env-var + shipped-fallback, resolved here (`resolve-telemetry.ts`)
7419
7626
  * and passed to the sink — the adapters read no environment. The single kill
7420
7627
  * switch is `HOLOCRON_TELEMETRY=false` (legacy alias `NO_HOLOCRON_TELEMETRY`);
7421
7628
  * see ADR-0007 / ADR-0008.
@@ -7548,7 +7755,7 @@ async function flush() {
7548
7755
  analytics = new NoopAnalyticsSink();
7549
7756
  }
7550
7757
  //#endregion
7551
- //#region src/commands/sync-github.ts
7758
+ //#region src/commands/sync-github/sync-github.ts
7552
7759
  const DEFAULT_REPO = "theholocron/.github";
7553
7760
  /**
7554
7761
  * The file set `sync-github` pushes to `theholocron/.github` — the reusable
@@ -7788,7 +7995,7 @@ async function runSyncGithub(input) {
7788
7995
  });
7789
7996
  }
7790
7997
  //#endregion
7791
- //#region src/commands/upgrade-deps.ts
7998
+ //#region src/commands/upgrade-deps/upgrade-deps.ts
7792
7999
  /**
7793
8000
  * `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
7794
8001
  * its `holocron.config.ts` up to the current major.
@@ -8016,7 +8223,7 @@ async function runUpgradeDeps(input = {}) {
8016
8223
  };
8017
8224
  }
8018
8225
  //#endregion
8019
- //#region src/commands/upgrade-node.ts
8226
+ //#region src/commands/upgrade-node/upgrade-node.ts
8020
8227
  const SKIP_DIRS = [
8021
8228
  "node_modules",
8022
8229
  ".git",
@@ -8194,268 +8401,45 @@ async function runUpgradeNode(input) {
8194
8401
  to,
8195
8402
  updated: updated.length,
8196
8403
  status
8197
- }, "upgrade node: done");
8198
- return {
8199
- status,
8200
- updated
8201
- };
8202
- }
8203
- //#endregion
8204
- //#region src/commit-lint/lint-message.ts
8205
- /**
8206
- * Lints one commit message string against an already-loaded commitlint
8207
- * config. The genuinely environment-independent half of commit-message
8208
- * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
8209
- * caller (Sentinel isolates itself from any ambient config since it runs
8210
- * standalone, away from a real repo checkout; `lintCommitMsgFile` below
8211
- * just lets cosmiconfig discover the invoking repo's own
8212
- * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
8213
- * but linting one message against a loaded config is identical either way.
8214
- *
8215
- * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
8216
- * itself uses internally — never a reimplementation of commitlint's rules.
8217
- */
8218
- /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
8219
- function selectParserOpts(parserPreset) {
8220
- return parserPreset?.parserOpts;
8221
- }
8222
- async function lintCommitMessage(message, loaded) {
8223
- const opts = {
8224
- /* istanbul ignore next -- see comment above */
8225
- parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
8226
- plugins: loaded.plugins,
8227
- /* istanbul ignore next -- see comment above */
8228
- ignores: loaded.ignores ?? [],
8229
- defaultIgnores: loaded.defaultIgnores !== false
8230
- };
8231
- const outcome = await lint(message, loaded.rules, opts);
8232
- if (outcome.valid) return [];
8233
- return outcome.errors.map((error) => ({
8234
- rule: error.name,
8235
- message: error.message
8236
- }));
8237
- }
8238
- //#endregion
8239
- //#region src/commit-lint/lint-commit-msg-file.ts
8240
- /**
8241
- * `holocron run lint commit-msg <file>` — a real programmatic replacement for
8242
- * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
8243
- * (holocron#789). Matches that CLI's own contract exactly: `file` is a
8244
- * path to the commit message being written (the arg git's `commit-msg`
8245
- * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
8246
- * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
8247
- * discovers the invoking repo's real `commitlint.config.*` file, exactly
8248
- * as `commitlint --edit` (no `--config` flag) already does today. Unlike
8249
- * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
8250
- * runs inside a real repo checkout with a real `node_modules`, not
8251
- * standalone on a server.
8252
- */
8253
- async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8254
- const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
8255
- return {
8256
- valid: violations.length === 0,
8257
- violations
8258
- };
8259
- }
8260
- //#endregion
8261
- //#region src/config/load-config.ts
8262
- /**
8263
- * `holocron.config.{ts,js,mjs,cjs,json}` loader.
8264
- *
8265
- * File discovery + module loading (typed configs via `tsx`, no build
8266
- * step) is delegated to `@theholocron/datapad`. This module keeps the
8267
- * holocron-specific parts: filling `name` / `repo.name` defaults and
8268
- * running the config through `resolveConfig`.
8269
- *
8270
- * Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
8271
- * see ADR-0010 / issue #75 / #81.
8272
- */
8273
- /**
8274
- * Read + parse + resolve `holocron.config.*` starting from the given
8275
- * directory, walking up through ancestors to the nearest `.git` boundary
8276
- * when `cwd` itself has no match — every command is usable from any
8277
- * subdirectory of a repo, not only its exact root (see
8278
- * `@theholocron/datapad`'s own doc comment for the stop conditions).
8279
- * Throws {@link ConfigFileError} when nothing is found or a file cannot
8280
- * be loaded, or `ConfigError` when the config fails schema checks.
8281
- */
8282
- async function loadConfig(cwd) {
8283
- const found = await loadConfigFile({
8284
- cwd,
8285
- name: "holocron",
8286
- walkUp: true
8287
- });
8288
- 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");
8289
8405
  return {
8290
- resolved: resolveConfig(await deriveDefaults(found)),
8291
- filepath: found.filepath
8406
+ status,
8407
+ updated
8292
8408
  };
8293
8409
  }
8294
- async function deriveDefaults(found) {
8295
- const configDir = dirname(found.filepath);
8296
- const result = { ...found.config };
8297
- if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
8298
- if (result.repo && !result.repo.name) {
8299
- const remote = await readGitRemote(configDir);
8300
- if (remote?.host === "github.com") result.repo = {
8301
- ...result.repo,
8302
- name: remote.slug
8303
- };
8304
- }
8305
- const manifest = await loadTasksConfig(configDir, { holocron: found });
8306
- if (manifest.tasks) result.tasks = manifest.tasks;
8307
- if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
8308
- if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
8309
- if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
8310
- if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
8311
- return result;
8312
- }
8313
- async function readPackageJsonName(dir) {
8314
- const name = (await readPackageJson(dir))?.name;
8315
- return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
8316
- }
8317
8410
  //#endregion
8318
- //#region src/interactive-menu.ts
8411
+ //#region src/interactive-menu/child-argv/child-argv.ts
8319
8412
  /**
8320
- * The interactive fallback — replaces every "run --help to see commands"
8321
- * dead end with a prompt. Three layers, all driven by {@link COMMAND_REGISTRY}:
8322
- *
8323
- * 1. `holocron` with no command → searchable picker over every command
8324
- * 2. a parent command with no sub → `select()` over that group's commands
8325
- * 3. a leaf command missing a → `input()` / `select()` per positional
8326
- * required positional
8327
- *
8328
- * Layers 1–2 are driven from `cli.ts`'s `$0` handlers via {@link launchMenu}:
8329
- * pick a command, prompt for its required positionals, then **spawn a fresh
8330
- * `holocron <command> …` child process** (`stdio: "inherit"`) rather than
8331
- * re-entering yargs' own parse pipeline — see the spec's "spawn vs re-parse"
8332
- * section (theholocron/holocron#438) for why. Layer 3 is driven directly from
8333
- * each leaf command's own handler in `cli.ts`, calling
8334
- * {@link promptForPositionals} for just that one command — no spawn needed,
8335
- * the handler already has everything else it needs.
8336
- *
8337
- * For a command whose handler honours `--dry-run` ({@link
8338
- * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8339
- * dry run doesn't mutate anything, so there's nothing to ask permission
8340
- * for — then, once that preview comes back clean and found something to
8341
- * do, asks the one real decision: "proceed with the real run?". A preview
8342
- * that finds nothing to do skips that question too. `--skip-dry-run` opts
8343
- * a top-level invocation out of the preview entirely, straight to the
8344
- * 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.
8345
8417
  *
8346
- * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
8347
- * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
8348
- * hanging on `@inquirer/prompts` (which otherwise throws its own
8349
- * `ExitPromptError` the moment stdin closes) — `cli.ts`'s `USER_FACING_ERRORS`
8350
- * catch prints its message cleanly, restoring the old demandCommand-style
8351
- * hard failure for non-interactive callers.
8352
- */
8353
- /** Raised when a prompt is needed but stdin isn't a TTY — see the module doc. */
8354
- var NonInteractiveError = class extends Error {
8355
- name = "NonInteractiveError";
8356
- };
8357
- const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
8358
- /**
8359
- * This repo's own resolved task manifest, filtered to what's actually
8360
- * runnable locally — `Astromech.runnableTasks()`, reused rather than
8361
- * reimplemented (deliberately *not* `packageScripts()`, whose
8362
- * `syncScripts: false` short-circuit returns `{}` for repos — this one
8363
- * included — that opt out of generated `package.json` scripts but still
8364
- * have real runnable tasks). Empty (never throws) when there's no config to
8365
- * resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
8366
- * as "run ›" simply having no children.
8367
- */
8368
- async function listRunnableTasks(argv) {
8369
- const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
8370
- const config = await loadTasksConfig(cwd).catch(() => void 0);
8371
- return createAstromech({
8372
- cwd,
8373
- config
8374
- }).runnableTasks();
8375
- }
8376
- /**
8377
- * Namespace display order for the `run` menu — deliberately its own thing,
8378
- * not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
8379
- * signal first" in an automated gate, a different goal from "how a person
8380
- * would naturally work through a repo" (build it, clean up its style, check
8381
- * it's secure, test it, validate it, document it). A namespace absent here
8382
- * (a repo-specific one this list doesn't know about) sorts after every
8383
- * named one, in whatever order it was first seen.
8384
- */
8385
- const RUN_MENU_NAMESPACE_ORDER = [
8386
- "delivery",
8387
- "sourceQuality",
8388
- "security",
8389
- "verification",
8390
- "platform",
8391
- "knowledge"
8392
- ];
8393
- /**
8394
- * Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
8395
- * groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
8396
- * within each group is `taskNames`' own order (already declaration order,
8397
- * from {@link listRunnableTasks}). A name with no `.` at all (defensive;
8398
- * every real registry task has a namespace) is its own bucket, keyed by the
8399
- * 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.
8400
8422
  */
8401
- function groupByNamespace(taskNames) {
8402
- const groups = /* @__PURE__ */ new Map();
8403
- for (const name of taskNames) {
8404
- const dot = name.indexOf(".");
8405
- const key = dot === -1 ? name : name.slice(0, dot);
8406
- const list = groups.get(key);
8407
- if (list) list.push(name);
8408
- else groups.set(key, [name]);
8409
- }
8410
- const ordered = /* @__PURE__ */ new Map();
8411
- const byMenuOrder = (a, b) => {
8412
- const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
8413
- const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
8414
- if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
8415
- return 0;
8416
- };
8417
- for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8418
- 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;
8419
8431
  }
8420
- /**
8421
- * Dynamically-built `run` sub-entries for every locally-runnable task
8422
- * ({@link listRunnableTasks}), grouped by namespace and merged into
8423
- * `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
8424
- * itself, since the task set is per-repo, not a static surface this module
8425
- * can hand-enumerate.
8426
- *
8427
- * Each namespace becomes a group with a leading "<namespace> (run it)" item
8428
- * (`holocron run <namespace>` — every task in it, via
8429
- * `Astromech.runGroup()`) alongside each of its individual tasks, the exact
8430
- * same "a group that's also runnable" shape `menuItems` already renders for
8431
- * `repo sync` / `deploy` — reached here for free by using a space, not the
8432
- * task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
8433
- * the real dotted invocation `buildChildArgv` actually spawns).
8434
- */
8435
- function buildRunMenuEntries(taskNames) {
8436
- const entries = [];
8437
- for (const [namespace, tasks] of groupByNamespace(taskNames)) {
8438
- const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
8439
- if (isRealNamespace) entries.push({
8440
- name: `run ${namespace}`,
8441
- description: `Run every ${namespace}.* task in sequence`,
8442
- positionals: [],
8443
- supportsDryRun: true,
8444
- spawnArgs: ["run", namespace]
8445
- });
8446
- for (const task of tasks) {
8447
- const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
8448
- entries.push({
8449
- name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
8450
- description: `Run ${task}`,
8451
- positionals: [],
8452
- supportsDryRun: true,
8453
- spawnArgs: ["run", task]
8454
- });
8455
- }
8456
- }
8457
- 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
+ ];
8458
8439
  }
8440
+ //#endregion
8441
+ //#region src/interactive-menu/command-registry/command-registry.ts
8442
+ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
8459
8443
  /**
8460
8444
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
8461
8445
  * yargs registrations (this module does not introspect yargs at runtime).
@@ -8693,6 +8677,9 @@ function getEntry(name) {
8693
8677
  if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
8694
8678
  return entry;
8695
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. */
8696
8683
  const GROUP = "group:";
8697
8684
  /** The "← back" item's value; only offered below the first level. */
8698
8685
  const BACK = "back";
@@ -8738,105 +8725,16 @@ function menuItems(entries, path = []) {
8738
8725
  const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
8739
8726
  items.push({
8740
8727
  name: `${word} ›`,
8741
- value: `${GROUP}${full}`,
8742
- description: children.join(", "),
8743
- haystack: haystackOf(family)
8744
- });
8745
- }
8746
- }
8747
- return items;
8748
- }
8749
- /**
8750
- * Searchable autocomplete over one menu level's `items`. `select()` would work too
8751
- * at these list sizes, but `search()` degrades gracefully as the surface grows and
8752
- * costs nothing when it doesn't. Resolves to the picked item's `value`.
8753
- */
8754
- async function pickMenuItem(items, message = "What would you like to do?") {
8755
- return search({
8756
- message,
8757
- source: (term) => searchChoices(items, term)
8758
- });
8759
- }
8760
- /**
8761
- * `@inquirer/search`'s `source` callback, factored out as a plain function —
8762
- * unit-testable directly instead of only through a mocked `search()` call.
8763
- * Empty/undefined `term` (nothing typed yet) returns every item.
8764
- */
8765
- function searchChoices(items, term) {
8766
- const needle = term?.toLowerCase();
8767
- const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
8768
- const rank = (i) => {
8769
- if (!needle) return 0;
8770
- const name = i.name.toLowerCase();
8771
- return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
8772
- };
8773
- return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
8774
- name: i.name,
8775
- value: i.value,
8776
- description: i.description
8777
- }));
8778
- }
8779
- /**
8780
- * For each of `entry.positionals` not already present in `argv`, prompts for
8781
- * it (`input()` or `select()`, per the positional's `type`) and returns the
8782
- * resolved values in positional order — ready to append to a spawned child's
8783
- * argv, or to use directly in the current handler (Layer 3).
8784
- *
8785
- * Throws {@link NonInteractiveError} the first time it would need to prompt
8786
- * on a non-TTY stdin, and again if a `select` positional's choices resolve
8787
- * empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
8788
- */
8789
- async function promptForPositionals(entry, argv) {
8790
- const out = [];
8791
- for (const positional of entry.positionals) {
8792
- const existing = argv[positional.key];
8793
- if (existing !== void 0 && existing !== "") {
8794
- out.push(String(existing));
8795
- continue;
8796
- }
8797
- if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
8798
- if (positional.type === "select") {
8799
- const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
8800
- if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
8801
- out.push(await select({
8802
- message: positional.message,
8803
- choices: choices.map((c) => ({ value: c }))
8804
- }));
8805
- } else out.push(await input({
8806
- message: positional.message,
8807
- ...positional.validate ? { validate: positional.validate } : {}
8808
- }));
8809
- }
8810
- return out;
8811
- }
8812
- /**
8813
- * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
8814
- * menu-launched child should inherit from the parent invocation. Everything
8815
- * else (command-specific options) was never captured by the top-level `$0`
8816
- * handler in the first place, so there's nothing else to forward.
8817
- *
8818
- * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8819
- * dry-run offer needs to force `--dry-run` on for the preview spawn and
8820
- * force it back off for the real-run spawn that follows, regardless of
8821
- * what was on the original invocation.
8822
- */
8823
- function forwardedFlags(argv, dryRunOverride) {
8824
- const out = [];
8825
- const tokens = argv.token;
8826
- for (const t of tokens ?? []) out.push("--token", t);
8827
- if (typeof argv.org === "string") out.push("--org", argv.org);
8828
- if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8829
- if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8830
- return out;
8831
- }
8832
- /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8833
- function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8834
- return [
8835
- ...entry.spawnArgs ?? entry.name.split(" "),
8836
- ...positionals,
8837
- ...forwardedFlags(parentArgv, dryRunOverride)
8838
- ];
8728
+ value: `${GROUP}${full}`,
8729
+ description: children.join(", "),
8730
+ haystack: haystackOf(family)
8731
+ });
8732
+ }
8733
+ }
8734
+ return items;
8839
8735
  }
8736
+ //#endregion
8737
+ //#region src/interactive-menu/launch-menu/launch-menu.ts
8840
8738
  /**
8841
8739
  * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8842
8740
  * its handler knows this specific `--dry-run` came from `launchMenu`'s own
@@ -8859,353 +8757,200 @@ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8859
8757
  * once the child exits. `extraEnv` layers on top — used only for {@link
8860
8758
  * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8861
8759
  */
8862
- function spawnChild(args, extraEnv) {
8863
- return new Promise((resolve) => {
8864
- const child = spawn(process.execPath, [process.argv[1], ...args], {
8865
- stdio: "inherit",
8866
- env: {
8867
- ...process.env,
8868
- NO_UPDATE_NOTIFIER: "1",
8869
- ...extraEnv
8870
- }
8871
- });
8872
- child.on("exit", (code) => resolve(code ?? 0));
8873
- child.on("error", () => resolve(1));
8874
- });
8875
- }
8876
- /**
8877
- * Whether `launchMenu` should auto-preview a picked command before running
8878
- * it for real — only when the command's handler actually honours
8879
- * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8880
- * invocation didn't pass `--skip-dry-run` to opt out of the preview
8881
- * entirely (an escape hatch for "I already know what I want, skip
8882
- * straight to the real run").
8883
- */
8884
- function shouldPreviewDryRun(entry, parentArgv) {
8885
- return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8886
- }
8887
- /**
8888
- * Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
8889
- * `[]` for the top), prompt for the picked command's required positionals, spawn
8890
- * it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
8891
- * the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
8892
- * parent's own telemetry flush / update-notifier tail still runs. The parent's
8893
- * own `command_completed` event fires with command name "unknown" (the middleware
8894
- * ran before any command was picked) — left as-is rather than suppressed; it's a
8895
- * real, useful signal ("the menu got used").
8896
- *
8897
- * Picking a group opens its submenu, which has a "← back" item; a command ends
8898
- * the walk. See {@link menuItems} for how a level is built.
8899
- *
8900
- * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8901
- * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8902
- * this always previews first — a dry run doesn't mutate anything, so
8903
- * there's nothing to ask permission for. A preview that exits non-zero
8904
- * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8905
- * would change) stops here too, skipping the follow-up question since the
8906
- * real run would have nothing to do either. Otherwise it asks "proceed
8907
- * with the real run?" before the real spawn — the one actual decision
8908
- * left to make, since this one does mutate something.
8909
- */
8910
- async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
8911
- if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8912
- const stack = [startPath];
8913
- let picked;
8914
- while (!picked) {
8915
- const path = stack[stack.length - 1];
8916
- const items = menuItems(entries, path);
8917
- if (stack.length > 1) items.push({
8918
- name: "← back",
8919
- value: BACK,
8920
- description: "Return to the previous menu",
8921
- haystack: "back"
8922
- });
8923
- const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
8924
- if (value === "back") stack.pop();
8925
- else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
8926
- else picked = entries.find((e) => e.name === value);
8927
- }
8928
- const positionals = await promptForPositionals(picked, {});
8929
- if (shouldPreviewDryRun(picked, parentArgv)) {
8930
- const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8931
- if (dryExit === 2) {
8932
- process.exitCode = 0;
8933
- return;
8934
- }
8935
- if (dryExit !== 0) {
8936
- process.exitCode = dryExit;
8937
- return;
8938
- }
8939
- if (!await confirm({
8940
- message: "Dry run complete — proceed with the real run?",
8941
- default: false
8942
- })) {
8943
- process.exitCode = 0;
8944
- return;
8945
- }
8946
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8947
- return;
8948
- }
8949
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
8950
- }
8951
- //#endregion
8952
- //#region src/ui/json-view.ts
8953
- function isContainer(value) {
8954
- return typeof value === "object" && value !== null;
8955
- }
8956
- function childrenOf(value) {
8957
- return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
8958
- }
8959
- function scalar(value) {
8960
- if (typeof value === "string") return chalk.green(JSON.stringify(value));
8961
- if (typeof value === "number") return chalk.yellow(String(value));
8962
- if (typeof value === "boolean") return chalk.magenta(String(value));
8963
- return chalk.dim(String(value === void 0 ? null : value));
8964
- }
8965
- function summary(value) {
8966
- const n = childrenOf(value).length;
8967
- const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
8968
- return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
8969
- }
8970
- /**
8971
- * Paths of every container deeper than `depth` levels are left out, so those
8972
- * render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
8973
- */
8974
- function expandToDepth(value, depth) {
8975
- const out = /* @__PURE__ */ new Set();
8976
- const walk = (v, path, level) => {
8977
- if (!isContainer(v) || level >= depth) return;
8978
- out.add(path);
8979
- for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
8980
- };
8981
- walk(value, "", 0);
8982
- return out;
8983
- }
8984
- /** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
8985
- function renderTree(value, { expanded }) {
8986
- const lines = [];
8987
- const walk = (v, path, level, label, trailing) => {
8988
- const indent = " ".repeat(level);
8989
- const prefix = `${indent}${label}`;
8990
- if (!isContainer(v)) {
8991
- lines.push({
8992
- path,
8993
- text: `${prefix}${scalar(v)}${trailing}`,
8994
- foldable: false,
8995
- expanded: false,
8996
- level
8997
- });
8998
- return;
8999
- }
9000
- const kids = childrenOf(v);
9001
- const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
9002
- if (kids.length === 0) {
9003
- lines.push({
9004
- path,
9005
- text: `${prefix}${open}${close}${trailing}`,
9006
- foldable: false,
9007
- expanded: false,
9008
- level
9009
- });
9010
- return;
9011
- }
9012
- if (!expanded.has(path)) {
9013
- lines.push({
9014
- path,
9015
- text: `${prefix}${summary(v)}${trailing}`,
9016
- foldable: true,
9017
- expanded: false,
9018
- level
9019
- });
9020
- return;
9021
- }
9022
- lines.push({
9023
- path,
9024
- text: `${prefix}${open}`,
9025
- foldable: true,
9026
- expanded: true,
9027
- level
9028
- });
9029
- kids.forEach(([k, child], i) => {
9030
- const comma = i < kids.length - 1 ? "," : "";
9031
- const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
9032
- walk(child, `${path}/${k}`, level + 1, childLabel, comma);
9033
- });
9034
- lines.push({
9035
- path: `${path}#close`,
9036
- text: `${indent}${close}${trailing}`,
9037
- foldable: false,
9038
- expanded: false,
9039
- level
9040
- });
9041
- };
9042
- walk(value, "", 0, "", "");
9043
- return lines;
9044
- }
9045
- /** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
9046
- function reduceKey(state, key, value, rows) {
9047
- const lines = renderTree(value, { expanded: state.expanded });
9048
- const cur = lines[state.cursor];
9049
- let { cursor } = state;
9050
- const expanded = new Set(state.expanded);
9051
- switch (key) {
9052
- case "up":
9053
- cursor = Math.max(0, cursor - 1);
9054
- break;
9055
- case "down":
9056
- cursor = Math.min(lines.length - 1, cursor + 1);
9057
- break;
9058
- case "right":
9059
- if (cur?.foldable) expanded.add(cur.path);
9060
- break;
9061
- case "left":
9062
- if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
9063
- else if (cur) {
9064
- const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
9065
- if (parent >= 0) cursor = parent;
9066
- }
9067
- break;
9068
- case "expandAll": return {
9069
- cursor,
9070
- top: state.top,
9071
- expanded: expandToDepth(value, Infinity)
9072
- };
9073
- case "collapseAll": return {
9074
- cursor: 0,
9075
- top: 0,
9076
- expanded: /* @__PURE__ */ new Set()
9077
- };
9078
- }
9079
- const count = renderTree(value, { expanded }).length;
9080
- cursor = Math.min(cursor, count - 1);
9081
- let top = state.top;
9082
- if (cursor < top) top = cursor;
9083
- if (cursor >= top + rows) top = cursor - rows + 1;
9084
- return {
9085
- cursor,
9086
- top: Math.max(0, top),
9087
- expanded
9088
- };
9089
- }
9090
- const KEYMAP = {
9091
- "\x1B[A": "up",
9092
- k: "up",
9093
- "\x1B[B": "down",
9094
- j: "down",
9095
- "\x1B[C": "right",
9096
- l: "right",
9097
- "\r": "right",
9098
- " ": "right",
9099
- "\x1B[D": "left",
9100
- h: "left",
9101
- E: "expandAll",
9102
- C: "collapseAll",
9103
- q: "quit",
9104
- "\x1B": "quit",
9105
- "": "quit"
9106
- };
9107
- function draw(out, value, state, rows) {
9108
- const lines = renderTree(value, { expanded: state.expanded });
9109
- const view = lines.slice(state.top, state.top + rows).map((l, i) => {
9110
- const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
9111
- 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));
9112
8772
  });
9113
- const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
9114
- out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
9115
8773
  }
9116
- async function runInteractive(value, depth) {
9117
- const { stdin, stdout } = process;
9118
- if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
9119
- const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
9120
- let state = {
9121
- cursor: 0,
9122
- top: 0,
9123
- expanded: expandToDepth(value, depth)
9124
- };
9125
- const wasRaw = stdin.isRaw;
9126
- stdin.setRawMode(true);
9127
- stdin.resume();
9128
- stdout.write("\x1B[?1049h\x1B[?25l");
9129
- const redraw = () => draw(stdout, value, state, rows());
9130
- stdout.on("resize", redraw);
9131
- try {
9132
- redraw();
9133
- await new Promise((resolve) => {
9134
- const onData = (buf) => {
9135
- const key = KEYMAP[buf.toString()];
9136
- if (key === "quit") {
9137
- stdin.off("data", onData);
9138
- resolve();
9139
- return;
9140
- }
9141
- if (key) {
9142
- state = reduceKey(state, key, value, rows());
9143
- redraw();
9144
- }
9145
- };
9146
- stdin.on("data", onData);
9147
- });
9148
- } finally {
9149
- stdout.off("resize", redraw);
9150
- stdout.write("\x1B[?25h\x1B[?1049l");
9151
- stdin.setRawMode(wasRaw);
9152
- stdin.pause();
9153
- }
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;
9154
8784
  }
9155
8785
  /**
9156
- * Print `value` as JSON. Non-TTY stdout always gets plain, valid
9157
- * `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
9158
- * 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.
9159
8807
  */
9160
- async function viewJson(value, options = {}) {
9161
- const depth = options.depth ?? Infinity;
9162
- if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
9163
- if (!process.stdout.isTTY) {
9164
- 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));
9165
8845
  return;
9166
8846
  }
9167
- const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
9168
- console.log(lines.map((l) => l.text).join("\n"));
8847
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
9169
8848
  }
9170
8849
  //#endregion
9171
- //#region src/ui/open-path.ts
9172
- /** Platform-appropriate command to open a path in the OS's file browser. */
9173
- function openCommand(path) {
9174
- if (process.platform === "darwin") return {
9175
- cmd: "open",
9176
- args: [path]
9177
- };
9178
- if (process.platform === "win32") return {
9179
- cmd: "cmd",
9180
- args: [
9181
- "/c",
9182
- "start",
9183
- "",
9184
- path
9185
- ]
9186
- };
9187
- return {
9188
- cmd: "xdg-open",
9189
- 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;
9190
8909
  };
8910
+ for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
8911
+ return ordered;
9191
8912
  }
9192
8913
  /**
9193
- * Open `path` in the OS's default file browser (Finder / Explorer /
9194
- * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
9195
- * it outlives this process, and never throws when the opener binary isn't
9196
- * 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).
9197
8927
  */
9198
- function openPath(path) {
9199
- const { cmd, args } = openCommand(path);
9200
- const child = spawn(cmd, args, {
9201
- stdio: "ignore",
9202
- detached: true
9203
- });
9204
- child.on("error", () => {});
9205
- 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;
9206
8951
  }
9207
8952
  //#endregion
9208
- //#region src/update-notifier.ts
8953
+ //#region src/update-notifier/update-notifier.ts
9209
8954
  const PACKAGE_NAME = "@theholocron/cli";
9210
8955
  const CACHE_TTL_MS = 864e5;
9211
8956
  const FETCH_TIMEOUT_MS = 3e3;