@theholocron/cli 5.0.0-alpha.121 → 5.0.0-alpha.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir, hostname, tmpdir, userInfo } from "node:os";
4
5
  import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
6
7
  import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
8
  import { loadTasksConfig } from "@theholocron/astromech/config";
9
+ import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
8
10
  import yargs from "yargs";
9
11
  import { hideBin } from "yargs/helpers";
10
12
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
@@ -12,18 +14,16 @@ import { createEnvLookup } from "@theholocron/env-utils";
12
14
  import { Entry, findCredentials } from "@napi-rs/keyring";
13
15
  import { pathToFileURL } from "node:url";
14
16
  import { errorMessage } from "@theholocron/misc-utils";
15
- import { createLogger, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
16
- import ora from "ora";
17
+ import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
17
18
  import chalk from "chalk";
19
+ import ora from "ora";
18
20
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
19
- import { homedir, hostname, userInfo } from "node:os";
20
21
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
21
22
  import { createHash } from "node:crypto";
22
23
  import { generateReadme } from "@theholocron/components-doc/markdown";
23
24
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
24
25
  import { createGitHubClient } from "@theholocron/github-client";
25
26
  import { PostHogSink } from "@theholocron/observability/analytics";
26
- import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
27
27
  import { SentrySink } from "@theholocron/observability/errors";
28
28
  import load from "@commitlint/load";
29
29
  import lint from "@commitlint/lint";
@@ -303,23 +303,52 @@ function resolveConfig(raw) {
303
303
  };
304
304
  }
305
305
  //#endregion
306
+ //#region src/ui/style.ts
307
+ const style = {
308
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
309
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
310
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
311
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
312
+ hint: (msg) => chalk.dim(msg),
313
+ dim: (msg) => chalk.dim(msg),
314
+ header: (msg) => chalk.bold(msg)
315
+ };
316
+ //#endregion
306
317
  //#region src/logger.ts
307
318
  /**
308
319
  * Resolve the explicit level to hand to `createLogger`, in priority order:
309
320
  *
310
- * 1. `--verbose` → `"debug"` 2. `--quiet` → `"error"`
311
- * 3. `HOLOCRON_LOG_LEVEL` env var
312
- * 4. `holocron.config` `log.level` (`configLevel`)
321
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
322
+ * 4. `HOLOCRON_LOG_LEVEL` env var
323
+ * 5. `holocron.config` `log.level` (`configLevel`)
313
324
  *
314
325
  * Returns `undefined` when nothing applies — `createLogger` then defaults
315
326
  * to `"info"`. Resolving the full chain here (rather than passing
316
327
  * `configLevel` straight through) keeps config below the env var.
317
328
  */
318
329
  function resolveLogLevel(argv, configLevel) {
330
+ if (argv.log) return argv.log;
319
331
  if (argv.verbose) return "debug";
320
332
  if (argv.quiet) return "error";
321
333
  return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
322
334
  }
335
+ /**
336
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
337
+ * at all, independent of level — the `@theholocron/observability`
338
+ * `consoleOutput` override.
339
+ *
340
+ * Local/interactive runs are quiet by default: any raw structured logger
341
+ * output (colorized or not) stays off regardless of the resolved level,
342
+ * unless the user explicitly asked to see it via `--log <level>`,
343
+ * `--verbose`, or `--quiet`. CI is untouched either way — returning
344
+ * `undefined` there preserves `createLogger`'s own `ci`/`tty`
345
+ * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
346
+ * unaffected by this flag.
347
+ */
348
+ function resolveConsoleOutput(argv) {
349
+ if (argv.log || argv.verbose || argv.quiet) return true;
350
+ return isCI() ? void 0 : false;
351
+ }
323
352
  let root;
324
353
  let rootLevel;
325
354
  let rootCommand;
@@ -358,13 +387,15 @@ function buildCliLogger(argv, opts = {}) {
358
387
  const { command, configLevel } = opts;
359
388
  if (command) rootCommand = command;
360
389
  const level = resolveLogLevel(argv, configLevel);
390
+ const consoleOutput = resolveConsoleOutput(argv);
361
391
  const axiom = resolveCliAxiom(opts);
362
392
  const axiomKey = axiom?.dataset;
363
- const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet;
393
+ const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
364
394
  if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
365
395
  const built = createLogger({
366
396
  ...level ? { level } : {},
367
- ...axiom ? { axiom } : {}
397
+ ...axiom ? { axiom } : {},
398
+ ...consoleOutput !== void 0 ? { consoleOutput } : {}
368
399
  });
369
400
  root = {
370
401
  logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
@@ -375,9 +406,35 @@ function buildCliLogger(argv, opts = {}) {
375
406
  }
376
407
  return root;
377
408
  }
378
- /** Lazily-memoized `Logger` for module-level call sites with no `argv` in scope. */
409
+ /**
410
+ * Lazily-memoized `Logger` for module-level call sites with no `argv` in
411
+ * scope — notably yargs' own `.fail()` handler, which fires on a
412
+ * pre-middleware validation failure (a missing required argument, an
413
+ * unknown command) and therefore runs before `buildCliLogger` has ever
414
+ * built the real root. Without an explicit `consoleOutput` here, that
415
+ * fallback would use `createLogger`'s bare auto-detection (pretty on any
416
+ * local TTY) and leak a structured block even though no flag asked for one
417
+ * — so it resolves the same "quiet unless CI" default `buildCliLogger`
418
+ * would, as if no flags were passed.
419
+ */
379
420
  function getLogger() {
380
- return (root ??= createLogger()).logger;
421
+ return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
422
+ }
423
+ /**
424
+ * Report a fatal, command-ending CLI error: always printed to the console
425
+ * (regardless of `consoleOutput` suppression) via `style.fail`, and also
426
+ * sent through the structured logger for Axiom/telemetry visibility.
427
+ *
428
+ * Use this when a handler is returning or exiting right here with nothing
429
+ * else for the user to see — a resolved `--token` failure, a yargs
430
+ * validation failure, a thrown domain error. Don't use it for a per-step
431
+ * warning inside a multi-step orchestrator that already prints its own
432
+ * `ok / fail / skip` summary — that summary is the user-facing signal;
433
+ * the per-step reason belongs in the structured log alone.
434
+ */
435
+ function reportError(message, log = getLogger()) {
436
+ console.error(style.fail(message));
437
+ log.error(message);
381
438
  }
382
439
  /** The current root logger's correlation id, if a root has been built. */
383
440
  function getRunId() {
@@ -637,17 +694,6 @@ async function withSpinner(label, fn) {
637
694
  }
638
695
  }
639
696
  //#endregion
640
- //#region src/ui/style.ts
641
- const style = {
642
- success: (msg) => `${chalk.green("✓")} ${msg}`,
643
- warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
644
- fail: (msg) => `${chalk.red("✗")} ${msg}`,
645
- step: (msg) => `${chalk.cyan("→")} ${msg}`,
646
- hint: (msg) => chalk.dim(msg),
647
- dim: (msg) => chalk.dim(msg),
648
- header: (msg) => chalk.bold(msg)
649
- };
650
- //#endregion
651
697
  //#region src/commands/auth.ts
652
698
  /**
653
699
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
@@ -1132,7 +1178,6 @@ async function runClone(input) {
1132
1178
  const fetchFn = input.fetch ?? globalThis.fetch;
1133
1179
  const dryRun = input.dryRun ?? false;
1134
1180
  const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
1135
- const label = input.repo ? `${input.org}/${input.repo}` : `org=${input.org}`;
1136
1181
  logger.info({
1137
1182
  org: input.org,
1138
1183
  repo: input.repo,
@@ -1145,7 +1190,6 @@ async function runClone(input) {
1145
1190
  stdio: "inherit"
1146
1191
  }).status };
1147
1192
  });
1148
- print(style.header(`Holocron clone — ${label} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
1149
1193
  if (!existsSync(targetDir)) {
1150
1194
  if (dryRun) print(style.dim(` would create ${targetDir}`));
1151
1195
  else mkdirSync(targetDir, { recursive: true });
@@ -1196,7 +1240,7 @@ async function runClone(input) {
1196
1240
  for (const repo of repos) {
1197
1241
  const dest = join(targetDir, repoDirName(repo));
1198
1242
  if (existsSync(dest)) {
1199
- print(style.dim(` skip ${repo.full_name}`));
1243
+ print(style.dim(` skip ${repo.full_name} → ${dest} (already exists)`));
1200
1244
  skipped++;
1201
1245
  continue;
1202
1246
  }
@@ -1205,10 +1249,10 @@ async function runClone(input) {
1205
1249
  cloned++;
1206
1250
  continue;
1207
1251
  }
1208
- print(style.step(` clone ${repo.full_name}`));
1252
+ print(style.step(` clone ${repo.full_name} → ${dest}`));
1209
1253
  const { clone_url } = repo;
1210
1254
  if (!clone_url.startsWith("https://github.com/")) {
1211
- print(style.fail(` failed ${repo.full_name} — unexpected clone URL: ${clone_url}`));
1255
+ print(style.fail(` failed ${repo.full_name} → unexpected clone URL: ${clone_url}`));
1212
1256
  failed++;
1213
1257
  continue;
1214
1258
  }
@@ -1216,7 +1260,7 @@ async function runClone(input) {
1216
1260
  try {
1217
1261
  encodedToken = encodeTokenForGitHttpAuth(input.token);
1218
1262
  } catch (err) {
1219
- print(style.fail(` failed ${repo.full_name} — invalid token format: ${errorMessage(err)}`));
1263
+ print(style.fail(` failed ${repo.full_name} → invalid token format: ${errorMessage(err)}`));
1220
1264
  failed++;
1221
1265
  continue;
1222
1266
  }
@@ -1229,12 +1273,13 @@ async function runClone(input) {
1229
1273
  print(style.fail(` failed ${repo.full_name}`));
1230
1274
  failed++;
1231
1275
  } else {
1232
- print(style.success(` cloned ${repo.name}`));
1276
+ print(style.success(` cloned ${repo.name} → ${dest}`));
1233
1277
  cloned++;
1234
1278
  }
1235
1279
  }
1236
1280
  const summary = `${cloned} cloned, ${skipped} skipped, ${failed} failed`;
1237
- print(dryRun ? style.dim(`\n dry-run: ${summary}`) : failed > 0 ? style.fail(`\n ${summary}`) : style.success(`\n ${summary}`));
1281
+ print("");
1282
+ print(dryRun ? style.dim(` dry-run: ${summary}`) : failed > 0 ? style.fail(` ${summary}`) : style.success(` ${summary}`));
1238
1283
  const status = dryRun ? "dry-run" : failed > 0 ? "fail" : "ok";
1239
1284
  logger[failed > 0 ? "warn" : "info"]({
1240
1285
  org: input.org,
@@ -1244,6 +1289,10 @@ async function runClone(input) {
1244
1289
  skipped,
1245
1290
  failed
1246
1291
  }, "clone: done");
1292
+ if (input.confirmOpen) {
1293
+ const openTarget = input.repo ? join(targetDir, repoDirName(repos[0])) : targetDir;
1294
+ if (existsSync(openTarget)) await input.confirmOpen(openTarget, dryRun && cloned > 0);
1295
+ }
1247
1296
  return {
1248
1297
  status,
1249
1298
  cloned,
@@ -8043,6 +8092,15 @@ function parseGitRemoteUrl(url) {
8043
8092
  * {@link promptForPositionals} for just that one command — no spawn needed,
8044
8093
  * the handler already has everything else it needs.
8045
8094
  *
8095
+ * For a command whose handler honours `--dry-run` ({@link
8096
+ * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8097
+ * dry run doesn't mutate anything, so there's nothing to ask permission
8098
+ * for — then, once that preview comes back clean and found something to
8099
+ * do, asks the one real decision: "proceed with the real run?". A preview
8100
+ * that finds nothing to do skips that question too. `--skip-dry-run` opts
8101
+ * a top-level invocation out of the preview entirely, straight to the
8102
+ * real run, for when you already know what you want.
8103
+ *
8046
8104
  * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
8047
8105
  * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
8048
8106
  * hanging on `@inquirer/prompts` (which otherwise throws its own
@@ -8072,17 +8130,20 @@ const COMMAND_REGISTRY = [
8072
8130
  message: "org/repo to clone (or a bare org to clone every repo in it):",
8073
8131
  type: "input",
8074
8132
  validate: (v) => v.trim().length > 0 || "org/repo or org is required"
8075
- }]
8133
+ }],
8134
+ supportsDryRun: true
8076
8135
  },
8077
8136
  {
8078
8137
  name: "doctor",
8079
8138
  description: "Load the config and run a smoke check against every provider",
8080
- positionals: []
8139
+ positionals: [],
8140
+ supportsDryRun: true
8081
8141
  },
8082
8142
  {
8083
8143
  name: "setup",
8084
8144
  description: "Apply infra setup actions across every configured capability",
8085
- positionals: []
8145
+ positionals: [],
8146
+ supportsDryRun: true
8086
8147
  },
8087
8148
  {
8088
8149
  name: "secret set",
@@ -8091,7 +8152,8 @@ const COMMAND_REGISTRY = [
8091
8152
  key: "name",
8092
8153
  message: "Secret name:",
8093
8154
  type: "input"
8094
- }]
8155
+ }],
8156
+ supportsDryRun: true
8095
8157
  },
8096
8158
  {
8097
8159
  name: "secrets sync",
@@ -8100,7 +8162,8 @@ const COMMAND_REGISTRY = [
8100
8162
  key: "environmentId",
8101
8163
  message: "Vault environment id:",
8102
8164
  type: "input"
8103
- }]
8165
+ }],
8166
+ supportsDryRun: true
8104
8167
  },
8105
8168
  {
8106
8169
  name: "deploy",
@@ -8109,7 +8172,8 @@ const COMMAND_REGISTRY = [
8109
8172
  key: "branch",
8110
8173
  message: "Branch to deploy:",
8111
8174
  type: "input"
8112
- }]
8175
+ }],
8176
+ supportsDryRun: true
8113
8177
  },
8114
8178
  {
8115
8179
  name: "cleanup-preview",
@@ -8119,7 +8183,8 @@ const COMMAND_REGISTRY = [
8119
8183
  message: "PR number:",
8120
8184
  type: "input",
8121
8185
  validate: numeric
8122
- }]
8186
+ }],
8187
+ supportsDryRun: true
8123
8188
  },
8124
8189
  {
8125
8190
  name: "bump-versions",
@@ -8129,32 +8194,38 @@ const COMMAND_REGISTRY = [
8129
8194
  cliArg: "new-version",
8130
8195
  message: "New version:",
8131
8196
  type: "input"
8132
- }]
8197
+ }],
8198
+ supportsDryRun: true
8133
8199
  },
8134
8200
  {
8135
8201
  name: "publish",
8136
8202
  description: "Publish @theholocron/* packages to npm",
8137
- positionals: []
8203
+ positionals: [],
8204
+ supportsDryRun: true
8138
8205
  },
8139
8206
  {
8140
8207
  name: "sync",
8141
8208
  description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
8142
- positionals: []
8209
+ positionals: [],
8210
+ supportsDryRun: true
8143
8211
  },
8144
8212
  {
8145
8213
  name: "ci",
8146
8214
  description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
8147
- positionals: []
8215
+ positionals: [],
8216
+ supportsDryRun: true
8148
8217
  },
8149
8218
  {
8150
8219
  name: "sync-github",
8151
8220
  description: "Sync workflow templates and composite actions to theholocron/.github",
8152
- positionals: []
8221
+ positionals: [],
8222
+ supportsDryRun: true
8153
8223
  },
8154
8224
  {
8155
8225
  name: "sync-readme",
8156
8226
  description: "Sync the Installation + Usage block in README.md from package.json",
8157
- positionals: []
8227
+ positionals: [],
8228
+ supportsDryRun: true
8158
8229
  },
8159
8230
  {
8160
8231
  name: "config show",
@@ -8164,7 +8235,8 @@ const COMMAND_REGISTRY = [
8164
8235
  {
8165
8236
  name: "new",
8166
8237
  description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
8167
- positionals: []
8238
+ positionals: [],
8239
+ supportsDryRun: true
8168
8240
  },
8169
8241
  {
8170
8242
  name: "plugin create",
@@ -8177,25 +8249,29 @@ const COMMAND_REGISTRY = [
8177
8249
  key: "vendor",
8178
8250
  message: "Vendor display name (PascalCase):",
8179
8251
  type: "input"
8180
- }]
8252
+ }],
8253
+ supportsDryRun: true
8181
8254
  },
8182
8255
  {
8183
8256
  name: "skills install",
8184
8257
  description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
8185
8258
  positionals: [],
8186
- group: "skills"
8259
+ group: "skills",
8260
+ supportsDryRun: true
8187
8261
  },
8188
8262
  {
8189
8263
  name: "skills remove",
8190
8264
  description: "Remove installed skills via npx skills remove",
8191
8265
  positionals: [],
8192
- group: "skills"
8266
+ group: "skills",
8267
+ supportsDryRun: true
8193
8268
  },
8194
8269
  {
8195
8270
  name: "skills update",
8196
8271
  description: "Update installed skills to their latest upstream versions via npx skills update",
8197
8272
  positionals: [],
8198
- group: "skills"
8273
+ group: "skills",
8274
+ supportsDryRun: true
8199
8275
  },
8200
8276
  {
8201
8277
  name: "upgrade node",
@@ -8206,13 +8282,15 @@ const COMMAND_REGISTRY = [
8206
8282
  type: "input",
8207
8283
  validate: numeric
8208
8284
  }],
8209
- group: "upgrade"
8285
+ group: "upgrade",
8286
+ supportsDryRun: true
8210
8287
  },
8211
8288
  {
8212
8289
  name: "upgrade deps",
8213
8290
  description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
8214
8291
  positionals: [],
8215
- group: "upgrade"
8292
+ group: "upgrade",
8293
+ supportsDryRun: true
8216
8294
  },
8217
8295
  {
8218
8296
  name: "auth set",
@@ -8327,25 +8405,41 @@ async function promptForPositionals(entry, argv) {
8327
8405
  * menu-launched child should inherit from the parent invocation. Everything
8328
8406
  * else (command-specific options) was never captured by the top-level `$0`
8329
8407
  * handler in the first place, so there's nothing else to forward.
8408
+ *
8409
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8410
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
8411
+ * force it back off for the real-run spawn that follows, regardless of
8412
+ * what was on the original invocation.
8330
8413
  */
8331
- function forwardedFlags(argv) {
8414
+ function forwardedFlags(argv, dryRunOverride) {
8332
8415
  const out = [];
8333
8416
  const tokens = argv.token;
8334
8417
  for (const t of tokens ?? []) out.push("--token", t);
8335
8418
  if (typeof argv.org === "string") out.push("--org", argv.org);
8336
8419
  if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8337
- if (argv.dryRun === true) out.push("--dry-run");
8420
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8338
8421
  return out;
8339
8422
  }
8340
8423
  /** `entry.name` split into tokens, resolved positionals appended, then forwarded flags. */
8341
- function buildChildArgv(entry, positionals, parentArgv) {
8424
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8342
8425
  return [
8343
8426
  ...entry.name.split(" "),
8344
8427
  ...positionals,
8345
- ...forwardedFlags(parentArgv)
8428
+ ...forwardedFlags(parentArgv, dryRunOverride)
8346
8429
  ];
8347
8430
  }
8348
8431
  /**
8432
+ * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8433
+ * its handler knows this specific `--dry-run` came from `launchMenu`'s own
8434
+ * preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
8435
+ * A handler that supports it uses this — never bare `argv.dryRun` — to
8436
+ * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
8437
+ * only has special meaning to `launchMenu` itself, so a direct/scripted
8438
+ * `--dry-run` call must never produce it — doing so unconditionally would
8439
+ * silently change the public exit-code contract for a successful dry run.
8440
+ */
8441
+ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8442
+ /**
8349
8443
  * Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
8350
8444
  *
8351
8445
  * `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
@@ -8353,15 +8447,17 @@ function buildChildArgv(entry, positionals, parentArgv) {
8353
8447
  * docstring), so without this the freshly-spawned child independently runs
8354
8448
  * its own `checkForUpdates()` too and the notice prints twice: once from
8355
8449
  * the child right after its command output, once more from the parent
8356
- * once the child exits.
8450
+ * once the child exits. `extraEnv` layers on top — used only for {@link
8451
+ * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8357
8452
  */
8358
- function spawnChild(args) {
8453
+ function spawnChild(args, extraEnv) {
8359
8454
  return new Promise((resolve) => {
8360
8455
  const child = spawn(process.execPath, [process.argv[1], ...args], {
8361
8456
  stdio: "inherit",
8362
8457
  env: {
8363
8458
  ...process.env,
8364
- NO_UPDATE_NOTIFIER: "1"
8459
+ NO_UPDATE_NOTIFIER: "1",
8460
+ ...extraEnv
8365
8461
  }
8366
8462
  });
8367
8463
  child.on("exit", (code) => resolve(code ?? 0));
@@ -8369,6 +8465,17 @@ function spawnChild(args) {
8369
8465
  });
8370
8466
  }
8371
8467
  /**
8468
+ * Whether `launchMenu` should auto-preview a picked command before running
8469
+ * it for real — only when the command's handler actually honours
8470
+ * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8471
+ * invocation didn't pass `--skip-dry-run` to opt out of the preview
8472
+ * entirely (an escape hatch for "I already know what I want, skip
8473
+ * straight to the real run").
8474
+ */
8475
+ function shouldPreviewDryRun(entry, parentArgv) {
8476
+ return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8477
+ }
8478
+ /**
8372
8479
  * Layers 1 and 2: pick a command from `entries`, prompt for its required
8373
8480
  * positionals, spawn it as a fresh `holocron` invocation, and set
8374
8481
  * `process.exitCode` from the child — the caller (a `$0` handler in
@@ -8377,12 +8484,79 @@ function spawnChild(args) {
8377
8484
  * `command_completed` event fires with command name "unknown" (the
8378
8485
  * middleware ran before any command was picked) — left as-is rather than
8379
8486
  * suppressed; it's a real, useful signal ("the menu got used").
8487
+ *
8488
+ * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8489
+ * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8490
+ * this always previews first — a dry run doesn't mutate anything, so
8491
+ * there's nothing to ask permission for. A preview that exits non-zero
8492
+ * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8493
+ * would change) stops here too, skipping the follow-up question since the
8494
+ * real run would have nothing to do either. Otherwise it asks "proceed
8495
+ * with the real run?" before the real spawn — the one actual decision
8496
+ * left to make, since this one does mutate something.
8380
8497
  */
8381
8498
  async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
8382
8499
  if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8383
8500
  const picked = await pickCommand(entries, pickMessage);
8384
- const childArgv = buildChildArgv(picked, await promptForPositionals(picked, {}), parentArgv);
8385
- process.exitCode = await spawnChild(childArgv);
8501
+ const positionals = await promptForPositionals(picked, {});
8502
+ if (shouldPreviewDryRun(picked, parentArgv)) {
8503
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8504
+ if (dryExit === 2) {
8505
+ process.exitCode = 0;
8506
+ return;
8507
+ }
8508
+ if (dryExit !== 0) {
8509
+ process.exitCode = dryExit;
8510
+ return;
8511
+ }
8512
+ if (!await confirm({
8513
+ message: "Dry run complete — proceed with the real run?",
8514
+ default: false
8515
+ })) {
8516
+ process.exitCode = 0;
8517
+ return;
8518
+ }
8519
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8520
+ return;
8521
+ }
8522
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
8523
+ }
8524
+ //#endregion
8525
+ //#region src/ui/open-path.ts
8526
+ /** Platform-appropriate command to open a path in the OS's file browser. */
8527
+ function openCommand(path) {
8528
+ if (process.platform === "darwin") return {
8529
+ cmd: "open",
8530
+ args: [path]
8531
+ };
8532
+ if (process.platform === "win32") return {
8533
+ cmd: "cmd",
8534
+ args: [
8535
+ "/c",
8536
+ "start",
8537
+ "",
8538
+ path
8539
+ ]
8540
+ };
8541
+ return {
8542
+ cmd: "xdg-open",
8543
+ args: [path]
8544
+ };
8545
+ }
8546
+ /**
8547
+ * Open `path` in the OS's default file browser (Finder / Explorer /
8548
+ * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
8549
+ * it outlives this process, and never throws when the opener binary isn't
8550
+ * on PATH (e.g. no `xdg-open` in a minimal container).
8551
+ */
8552
+ function openPath(path) {
8553
+ const { cmd, args } = openCommand(path);
8554
+ const child = spawn(cmd, args, {
8555
+ stdio: "ignore",
8556
+ detached: true
8557
+ });
8558
+ child.on("error", () => {});
8559
+ child.unref();
8386
8560
  }
8387
8561
  //#endregion
8388
8562
  //#region src/update-notifier.ts
@@ -8528,6 +8702,19 @@ function resolveOrg(argv, config) {
8528
8702
  return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
8529
8703
  }
8530
8704
  /**
8705
+ * Print the dim version line once per terminal session — keyed by the
8706
+ * parent shell's PID, since that's stable across repeated bare `holocron`
8707
+ * invocations in the same shell but changes in a new tab/window. A marker
8708
+ * file in the OS tmpdir tracks whether this shell has already seen it, so
8709
+ * re-running `holocron` doesn't repeat the line every time.
8710
+ */
8711
+ function printVersionHeaderOnce() {
8712
+ const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
8713
+ if (existsSync(marker)) return;
8714
+ console.log(style.dim(`v${CLI_VERSION}`));
8715
+ writeFileSync(marker, "");
8716
+ }
8717
+ /**
8531
8718
  * `buildCliLogger` options derived from a loaded `holocron.config` — the
8532
8719
  * level, the Axiom dataset, and the resolved org for a keyring-backed
8533
8720
  * Axiom token. Used by every handler that has already called `loadConfig`.
@@ -8557,7 +8744,7 @@ function tokenContext(rawTokens) {
8557
8744
  return parseTokenArgs(rawTokens);
8558
8745
  } catch (err) {
8559
8746
  if (err instanceof TokenParseError) {
8560
- getLogger().error(`--token: ${err.message}`);
8747
+ reportError(`--token: ${err.message}`);
8561
8748
  process.exitCode = 1;
8562
8749
  return null;
8563
8750
  }
@@ -8574,6 +8761,10 @@ try {
8574
8761
  type: "boolean",
8575
8762
  default: false,
8576
8763
  describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
8764
+ }).option("skip-dry-run", {
8765
+ type: "boolean",
8766
+ default: false,
8767
+ describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
8577
8768
  }).option("token", {
8578
8769
  type: "string",
8579
8770
  array: true,
@@ -8597,6 +8788,10 @@ try {
8597
8788
  type: "boolean",
8598
8789
  default: false,
8599
8790
  describe: "Set the log level to error — suppress info and warn."
8791
+ }).option("log", {
8792
+ type: "string",
8793
+ choices: LOG_LEVELS,
8794
+ describe: "Show structured logger output at this level. Local/interactive runs are quiet by default (no console output regardless of level) — this opts back in."
8600
8795
  }).middleware((argv) => {
8601
8796
  const name = argv._.slice(0, 2).join(" ") || "unknown";
8602
8797
  printRunId = Boolean(argv.debug || argv.verbose);
@@ -8618,13 +8813,13 @@ try {
8618
8813
  try {
8619
8814
  token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
8620
8815
  } catch (err) {
8621
- getLogger().error(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8816
+ reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8622
8817
  process.exitCode = 1;
8623
8818
  return;
8624
8819
  }
8625
8820
  const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
8626
8821
  const { org, repo } = parseCloneTarget(targetInput);
8627
- if ((await runClone({
8822
+ const report = await runClone({
8628
8823
  org,
8629
8824
  ...repo ? { repo } : {},
8630
8825
  token,
@@ -8637,8 +8832,20 @@ try {
8637
8832
  message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
8638
8833
  default: false
8639
8834
  });
8640
- } } : {}
8641
- })).status === "fail") process.exitCode = 1;
8835
+ } } : {},
8836
+ confirmOpen: async (openTarget, moreToCome) => {
8837
+ if (!process.stdin.isTTY) return;
8838
+ if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
8839
+ if (await confirm({
8840
+ message: `Open ${openTarget}?`,
8841
+ default: false
8842
+ })) openPath(openTarget);
8843
+ }
8844
+ });
8845
+ if (report.status === "fail") {
8846
+ if (report.message) reportError(`clone: ${report.message}`);
8847
+ process.exitCode = 1;
8848
+ } else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
8642
8849
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
8643
8850
  type: "string",
8644
8851
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -8898,7 +9105,7 @@ try {
8898
9105
  if (argv.syncTrust) {
8899
9106
  const [oldFile, newFile] = argv.syncTrust;
8900
9107
  if (!oldFile || !newFile) {
8901
- getLogger().error("publish --sync-trust requires exactly two values: <old-file> <new-file>");
9108
+ reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
8902
9109
  process.exitCode = 1;
8903
9110
  return;
8904
9111
  }
@@ -9058,7 +9265,7 @@ try {
9058
9265
  else try {
9059
9266
  token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
9060
9267
  } catch (err) {
9061
- getLogger().error(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9268
+ reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9062
9269
  process.exitCode = 1;
9063
9270
  return;
9064
9271
  }
@@ -9078,13 +9285,17 @@ try {
9078
9285
  }), async (argv) => {
9079
9286
  const loaded = await loadConfig(argv.cwd);
9080
9287
  applyResolvedConfig(argv, loaded.resolved);
9081
- if ((await runSyncReadme({
9288
+ const report = await runSyncReadme({
9082
9289
  loaded,
9083
9290
  context: {
9084
9291
  repoRoot: argv.cwd,
9085
9292
  dryRun: argv.dryRun
9086
9293
  }
9087
- })).status === "fail") process.exitCode = 1;
9294
+ });
9295
+ if (report.status === "fail") {
9296
+ if (report.message) reportError(`sync-readme: ${report.message}`);
9297
+ process.exitCode = 1;
9298
+ }
9088
9299
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
9089
9300
  const loaded = await loadConfig(argv.cwd);
9090
9301
  console.log(JSON.stringify(loaded.resolved, null, 2));
@@ -9313,12 +9524,12 @@ try {
9313
9524
  }) === "yes";
9314
9525
  if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
9315
9526
  if (!type) {
9316
- getLogger().error("new: template type is required");
9527
+ reportError("new: template type is required");
9317
9528
  process.exitCode = 1;
9318
9529
  return;
9319
9530
  }
9320
9531
  if (!name) {
9321
- getLogger().error("new: repo name is required");
9532
+ reportError("new: repo name is required");
9322
9533
  process.exitCode = 1;
9323
9534
  return;
9324
9535
  }
@@ -9347,7 +9558,7 @@ try {
9347
9558
  })).status === "fail") process.exitCode = 1;
9348
9559
  } catch (err) {
9349
9560
  if (err instanceof NewError) {
9350
- getLogger().error(`new: ${err.message}`);
9561
+ reportError(`new: ${err.message}`);
9351
9562
  process.exitCode = 1;
9352
9563
  return;
9353
9564
  }
@@ -9406,7 +9617,7 @@ try {
9406
9617
  }).status === "fail") process.exitCode = 1;
9407
9618
  } catch (err) {
9408
9619
  if (err instanceof PluginCreateError) {
9409
- getLogger().error(`plugin create: ${err.message}`);
9620
+ reportError(`plugin create: ${err.message}`);
9410
9621
  process.exitCode = 1;
9411
9622
  return;
9412
9623
  }
@@ -9434,7 +9645,7 @@ try {
9434
9645
  extra
9435
9646
  });
9436
9647
  if (report.status === "fail") {
9437
- if (report.message) getLogger().error(`upgrade node: ${report.message}`);
9648
+ if (report.message) reportError(`upgrade node: ${report.message}`);
9438
9649
  process.exitCode = 1;
9439
9650
  }
9440
9651
  }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
@@ -9448,7 +9659,7 @@ try {
9448
9659
  pinsOnly: argv.pinsOnly
9449
9660
  });
9450
9661
  if (report.status === "fail") {
9451
- if (report.message) getLogger().error(`upgrade deps: ${report.message}`);
9662
+ if (report.message) reportError(`upgrade deps: ${report.message}`);
9452
9663
  process.exitCode = 1;
9453
9664
  }
9454
9665
  }).command("$0", false, () => {}, async (argv) => {
@@ -9474,25 +9685,25 @@ try {
9474
9685
  }).command("$0", false, () => {}, async (argv) => {
9475
9686
  await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "auth"), argv, "auth — choose a subcommand:", "Run `holocron auth --help` to see available auth subcommands.");
9476
9687
  }), () => {}).command("$0", false, () => {}, async (argv) => {
9477
- console.log(style.dim(`holocron v${CLI_VERSION}`));
9688
+ printVersionHeaderOnce();
9478
9689
  await launchMenu(COMMAND_REGISTRY.filter((e) => !e.group), argv);
9479
9690
  }).strict().help().epilogue(`Execution contexts:
9480
9691
  global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
9481
9692
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
9482
9693
  captureException(err);
9483
- getLogger().error(err.message);
9694
+ reportError(err.message);
9484
9695
  errorReported = true;
9485
9696
  process.exitCode = 1;
9486
9697
  return;
9487
9698
  }
9488
9699
  if (err) throw err;
9489
- getLogger().error(msg);
9700
+ reportError(msg);
9490
9701
  errorReported = true;
9491
9702
  process.exitCode = 1;
9492
9703
  }).parse();
9493
9704
  } catch (err) {
9494
9705
  captureException(err);
9495
- if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
9706
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
9496
9707
  if (!process.exitCode) process.exitCode = 1;
9497
9708
  }
9498
9709
  finishCommand(!process.exitCode);