@theholocron/cli 5.0.0-alpha.120 → 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
- import { checkbox, input, select } from "@inquirer/prompts";
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
@@ -1072,12 +1118,34 @@ async function runCleanupPreview(input) {
1072
1118
  }
1073
1119
  //#endregion
1074
1120
  //#region src/commands/clone.ts
1121
+ /**
1122
+ * Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
1123
+ * `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
1124
+ * repo. The repo half is never silently discarded: if it's there, it's used.
1125
+ * A trailing slash with nothing after it (`"theholocron/"`) is treated the
1126
+ * same as a bare org.
1127
+ */
1128
+ function parseCloneTarget(raw) {
1129
+ const trimmed = raw.trim();
1130
+ const slash = trimmed.indexOf("/");
1131
+ if (slash === -1) return { org: trimmed };
1132
+ const org = trimmed.slice(0, slash);
1133
+ const repo = trimmed.slice(slash + 1);
1134
+ return repo ? {
1135
+ org,
1136
+ repo
1137
+ } : { org };
1138
+ }
1075
1139
  function encodeTokenForGitHttpAuth(token) {
1076
1140
  const trimmed = token.trim();
1077
1141
  if (!trimmed) throw new Error("empty token");
1078
1142
  if (/[\u0000-\u001F\u007F\s]/.test(trimmed)) throw new Error("token contains whitespace or control characters");
1079
1143
  return encodeURIComponent(trimmed);
1080
1144
  }
1145
+ /** Strip a leading dot so a hidden repo (e.g. `.github`) is still visible in Finder once cloned. */
1146
+ function repoDirName(repo) {
1147
+ return repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
1148
+ }
1081
1149
  async function listOrgRepos(org, token, fetchFn) {
1082
1150
  const repos = [];
1083
1151
  let url = `https://api.github.com/orgs/${org}/repos?per_page=100&type=all`;
@@ -1094,6 +1162,16 @@ async function listOrgRepos(org, token, fetchFn) {
1094
1162
  }
1095
1163
  return repos;
1096
1164
  }
1165
+ async function getRepo(org, repo, token, fetchFn) {
1166
+ const res = await fetchFn(`https://api.github.com/repos/${org}/${repo}`, { headers: {
1167
+ Authorization: `Bearer ${token}`,
1168
+ Accept: "application/vnd.github+json",
1169
+ "X-GitHub-Api-Version": "2022-11-28"
1170
+ } });
1171
+ if (res.status === 404) throw new Error(`${org}/${repo} not found — check the repo name and that the token can see it`);
1172
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText} — check that the token has repo:read scope`);
1173
+ return await res.json();
1174
+ }
1097
1175
  async function runClone(input) {
1098
1176
  const print = input.print ?? ((line) => console.log(line));
1099
1177
  const logger = input.logger ?? getLogger();
@@ -1102,6 +1180,7 @@ async function runClone(input) {
1102
1180
  const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
1103
1181
  logger.info({
1104
1182
  org: input.org,
1183
+ repo: input.repo,
1105
1184
  targetDir,
1106
1185
  dryRun: dryRun || void 0
1107
1186
  }, "clone: start");
@@ -1111,20 +1190,20 @@ async function runClone(input) {
1111
1190
  stdio: "inherit"
1112
1191
  }).status };
1113
1192
  });
1114
- print(style.header(`Holocron clone — org=${input.org} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
1115
1193
  if (!existsSync(targetDir)) {
1116
1194
  if (dryRun) print(style.dim(` would create ${targetDir}`));
1117
1195
  else mkdirSync(targetDir, { recursive: true });
1118
1196
  }
1119
1197
  let repos;
1120
1198
  try {
1121
- repos = await listOrgRepos(input.org, input.token, fetchFn);
1199
+ repos = input.repo ? [await getRepo(input.org, input.repo, input.token, fetchFn)] : await listOrgRepos(input.org, input.token, fetchFn);
1122
1200
  } catch (err) {
1123
1201
  const message = errorMessage(err);
1124
1202
  logger.warn({
1125
1203
  org: input.org,
1204
+ repo: input.repo,
1126
1205
  reason: message
1127
- }, "clone: failed to list org repos");
1206
+ }, "clone: failed to list repos");
1128
1207
  return {
1129
1208
  status: "fail",
1130
1209
  cloned: 0,
@@ -1133,14 +1212,35 @@ async function runClone(input) {
1133
1212
  message
1134
1213
  };
1135
1214
  }
1215
+ if (!input.repo && !dryRun && input.confirmWholeOrg) {
1216
+ const alreadyCloned = repos.filter((r) => existsSync(join(targetDir, repoDirName(r)))).length;
1217
+ const scope = {
1218
+ total: repos.length,
1219
+ alreadyCloned,
1220
+ toClone: repos.length - alreadyCloned
1221
+ };
1222
+ if (!await input.confirmWholeOrg(scope)) {
1223
+ print(style.dim(" aborted — nothing cloned"));
1224
+ logger.info({
1225
+ org: input.org,
1226
+ ...scope
1227
+ }, "clone: aborted by user");
1228
+ return {
1229
+ status: "aborted",
1230
+ cloned: 0,
1231
+ skipped: 0,
1232
+ failed: 0,
1233
+ message: "aborted by user"
1234
+ };
1235
+ }
1236
+ }
1136
1237
  let cloned = 0;
1137
1238
  let skipped = 0;
1138
1239
  let failed = 0;
1139
1240
  for (const repo of repos) {
1140
- const dirName = repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
1141
- const dest = join(targetDir, dirName);
1241
+ const dest = join(targetDir, repoDirName(repo));
1142
1242
  if (existsSync(dest)) {
1143
- print(style.dim(` skip ${repo.full_name}`));
1243
+ print(style.dim(` skip ${repo.full_name} → ${dest} (already exists)`));
1144
1244
  skipped++;
1145
1245
  continue;
1146
1246
  }
@@ -1149,10 +1249,10 @@ async function runClone(input) {
1149
1249
  cloned++;
1150
1250
  continue;
1151
1251
  }
1152
- print(style.step(` clone ${repo.full_name}`));
1252
+ print(style.step(` clone ${repo.full_name} → ${dest}`));
1153
1253
  const { clone_url } = repo;
1154
1254
  if (!clone_url.startsWith("https://github.com/")) {
1155
- 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}`));
1156
1256
  failed++;
1157
1257
  continue;
1158
1258
  }
@@ -1160,7 +1260,7 @@ async function runClone(input) {
1160
1260
  try {
1161
1261
  encodedToken = encodeTokenForGitHttpAuth(input.token);
1162
1262
  } catch (err) {
1163
- 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)}`));
1164
1264
  failed++;
1165
1265
  continue;
1166
1266
  }
@@ -1173,20 +1273,26 @@ async function runClone(input) {
1173
1273
  print(style.fail(` failed ${repo.full_name}`));
1174
1274
  failed++;
1175
1275
  } else {
1176
- print(style.success(` cloned ${repo.name}`));
1276
+ print(style.success(` cloned ${repo.name} → ${dest}`));
1177
1277
  cloned++;
1178
1278
  }
1179
1279
  }
1180
1280
  const summary = `${cloned} cloned, ${skipped} skipped, ${failed} failed`;
1181
- 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}`));
1182
1283
  const status = dryRun ? "dry-run" : failed > 0 ? "fail" : "ok";
1183
1284
  logger[failed > 0 ? "warn" : "info"]({
1184
1285
  org: input.org,
1286
+ repo: input.repo,
1185
1287
  status,
1186
1288
  cloned,
1187
1289
  skipped,
1188
1290
  failed
1189
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
+ }
1190
1296
  return {
1191
1297
  status,
1192
1298
  cloned,
@@ -7986,6 +8092,15 @@ function parseGitRemoteUrl(url) {
7986
8092
  * {@link promptForPositionals} for just that one command — no spawn needed,
7987
8093
  * the handler already has everything else it needs.
7988
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
+ *
7989
8104
  * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
7990
8105
  * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
7991
8106
  * hanging on `@inquirer/prompts` (which otherwise throws its own
@@ -8009,18 +8124,26 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
8009
8124
  const COMMAND_REGISTRY = [
8010
8125
  {
8011
8126
  name: "clone",
8012
- description: "Clone all repos in a GitHub org as siblings under a single directory",
8013
- positionals: []
8127
+ description: "Clone a single repo (org/repo) or every repo in an org (org)",
8128
+ positionals: [{
8129
+ key: "target",
8130
+ message: "org/repo to clone (or a bare org to clone every repo in it):",
8131
+ type: "input",
8132
+ validate: (v) => v.trim().length > 0 || "org/repo or org is required"
8133
+ }],
8134
+ supportsDryRun: true
8014
8135
  },
8015
8136
  {
8016
8137
  name: "doctor",
8017
8138
  description: "Load the config and run a smoke check against every provider",
8018
- positionals: []
8139
+ positionals: [],
8140
+ supportsDryRun: true
8019
8141
  },
8020
8142
  {
8021
8143
  name: "setup",
8022
8144
  description: "Apply infra setup actions across every configured capability",
8023
- positionals: []
8145
+ positionals: [],
8146
+ supportsDryRun: true
8024
8147
  },
8025
8148
  {
8026
8149
  name: "secret set",
@@ -8029,7 +8152,8 @@ const COMMAND_REGISTRY = [
8029
8152
  key: "name",
8030
8153
  message: "Secret name:",
8031
8154
  type: "input"
8032
- }]
8155
+ }],
8156
+ supportsDryRun: true
8033
8157
  },
8034
8158
  {
8035
8159
  name: "secrets sync",
@@ -8038,7 +8162,8 @@ const COMMAND_REGISTRY = [
8038
8162
  key: "environmentId",
8039
8163
  message: "Vault environment id:",
8040
8164
  type: "input"
8041
- }]
8165
+ }],
8166
+ supportsDryRun: true
8042
8167
  },
8043
8168
  {
8044
8169
  name: "deploy",
@@ -8047,7 +8172,8 @@ const COMMAND_REGISTRY = [
8047
8172
  key: "branch",
8048
8173
  message: "Branch to deploy:",
8049
8174
  type: "input"
8050
- }]
8175
+ }],
8176
+ supportsDryRun: true
8051
8177
  },
8052
8178
  {
8053
8179
  name: "cleanup-preview",
@@ -8057,7 +8183,8 @@ const COMMAND_REGISTRY = [
8057
8183
  message: "PR number:",
8058
8184
  type: "input",
8059
8185
  validate: numeric
8060
- }]
8186
+ }],
8187
+ supportsDryRun: true
8061
8188
  },
8062
8189
  {
8063
8190
  name: "bump-versions",
@@ -8067,32 +8194,38 @@ const COMMAND_REGISTRY = [
8067
8194
  cliArg: "new-version",
8068
8195
  message: "New version:",
8069
8196
  type: "input"
8070
- }]
8197
+ }],
8198
+ supportsDryRun: true
8071
8199
  },
8072
8200
  {
8073
8201
  name: "publish",
8074
8202
  description: "Publish @theholocron/* packages to npm",
8075
- positionals: []
8203
+ positionals: [],
8204
+ supportsDryRun: true
8076
8205
  },
8077
8206
  {
8078
8207
  name: "sync",
8079
8208
  description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
8080
- positionals: []
8209
+ positionals: [],
8210
+ supportsDryRun: true
8081
8211
  },
8082
8212
  {
8083
8213
  name: "ci",
8084
8214
  description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
8085
- positionals: []
8215
+ positionals: [],
8216
+ supportsDryRun: true
8086
8217
  },
8087
8218
  {
8088
8219
  name: "sync-github",
8089
8220
  description: "Sync workflow templates and composite actions to theholocron/.github",
8090
- positionals: []
8221
+ positionals: [],
8222
+ supportsDryRun: true
8091
8223
  },
8092
8224
  {
8093
8225
  name: "sync-readme",
8094
8226
  description: "Sync the Installation + Usage block in README.md from package.json",
8095
- positionals: []
8227
+ positionals: [],
8228
+ supportsDryRun: true
8096
8229
  },
8097
8230
  {
8098
8231
  name: "config show",
@@ -8102,7 +8235,8 @@ const COMMAND_REGISTRY = [
8102
8235
  {
8103
8236
  name: "new",
8104
8237
  description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
8105
- positionals: []
8238
+ positionals: [],
8239
+ supportsDryRun: true
8106
8240
  },
8107
8241
  {
8108
8242
  name: "plugin create",
@@ -8115,25 +8249,29 @@ const COMMAND_REGISTRY = [
8115
8249
  key: "vendor",
8116
8250
  message: "Vendor display name (PascalCase):",
8117
8251
  type: "input"
8118
- }]
8252
+ }],
8253
+ supportsDryRun: true
8119
8254
  },
8120
8255
  {
8121
8256
  name: "skills install",
8122
8257
  description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
8123
8258
  positionals: [],
8124
- group: "skills"
8259
+ group: "skills",
8260
+ supportsDryRun: true
8125
8261
  },
8126
8262
  {
8127
8263
  name: "skills remove",
8128
8264
  description: "Remove installed skills via npx skills remove",
8129
8265
  positionals: [],
8130
- group: "skills"
8266
+ group: "skills",
8267
+ supportsDryRun: true
8131
8268
  },
8132
8269
  {
8133
8270
  name: "skills update",
8134
8271
  description: "Update installed skills to their latest upstream versions via npx skills update",
8135
8272
  positionals: [],
8136
- group: "skills"
8273
+ group: "skills",
8274
+ supportsDryRun: true
8137
8275
  },
8138
8276
  {
8139
8277
  name: "upgrade node",
@@ -8144,13 +8282,15 @@ const COMMAND_REGISTRY = [
8144
8282
  type: "input",
8145
8283
  validate: numeric
8146
8284
  }],
8147
- group: "upgrade"
8285
+ group: "upgrade",
8286
+ supportsDryRun: true
8148
8287
  },
8149
8288
  {
8150
8289
  name: "upgrade deps",
8151
8290
  description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
8152
8291
  positionals: [],
8153
- group: "upgrade"
8292
+ group: "upgrade",
8293
+ supportsDryRun: true
8154
8294
  },
8155
8295
  {
8156
8296
  name: "auth set",
@@ -8265,25 +8405,41 @@ async function promptForPositionals(entry, argv) {
8265
8405
  * menu-launched child should inherit from the parent invocation. Everything
8266
8406
  * else (command-specific options) was never captured by the top-level `$0`
8267
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.
8268
8413
  */
8269
- function forwardedFlags(argv) {
8414
+ function forwardedFlags(argv, dryRunOverride) {
8270
8415
  const out = [];
8271
8416
  const tokens = argv.token;
8272
8417
  for (const t of tokens ?? []) out.push("--token", t);
8273
8418
  if (typeof argv.org === "string") out.push("--org", argv.org);
8274
8419
  if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8275
- if (argv.dryRun === true) out.push("--dry-run");
8420
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8276
8421
  return out;
8277
8422
  }
8278
8423
  /** `entry.name` split into tokens, resolved positionals appended, then forwarded flags. */
8279
- function buildChildArgv(entry, positionals, parentArgv) {
8424
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8280
8425
  return [
8281
8426
  ...entry.name.split(" "),
8282
8427
  ...positionals,
8283
- ...forwardedFlags(parentArgv)
8428
+ ...forwardedFlags(parentArgv, dryRunOverride)
8284
8429
  ];
8285
8430
  }
8286
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
+ /**
8287
8443
  * Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
8288
8444
  *
8289
8445
  * `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
@@ -8291,15 +8447,17 @@ function buildChildArgv(entry, positionals, parentArgv) {
8291
8447
  * docstring), so without this the freshly-spawned child independently runs
8292
8448
  * its own `checkForUpdates()` too and the notice prints twice: once from
8293
8449
  * the child right after its command output, once more from the parent
8294
- * 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.
8295
8452
  */
8296
- function spawnChild(args) {
8453
+ function spawnChild(args, extraEnv) {
8297
8454
  return new Promise((resolve) => {
8298
8455
  const child = spawn(process.execPath, [process.argv[1], ...args], {
8299
8456
  stdio: "inherit",
8300
8457
  env: {
8301
8458
  ...process.env,
8302
- NO_UPDATE_NOTIFIER: "1"
8459
+ NO_UPDATE_NOTIFIER: "1",
8460
+ ...extraEnv
8303
8461
  }
8304
8462
  });
8305
8463
  child.on("exit", (code) => resolve(code ?? 0));
@@ -8307,6 +8465,17 @@ function spawnChild(args) {
8307
8465
  });
8308
8466
  }
8309
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
+ /**
8310
8479
  * Layers 1 and 2: pick a command from `entries`, prompt for its required
8311
8480
  * positionals, spawn it as a fresh `holocron` invocation, and set
8312
8481
  * `process.exitCode` from the child — the caller (a `$0` handler in
@@ -8315,12 +8484,79 @@ function spawnChild(args) {
8315
8484
  * `command_completed` event fires with command name "unknown" (the
8316
8485
  * middleware ran before any command was picked) — left as-is rather than
8317
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.
8318
8497
  */
8319
8498
  async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
8320
8499
  if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8321
8500
  const picked = await pickCommand(entries, pickMessage);
8322
- const childArgv = buildChildArgv(picked, await promptForPositionals(picked, {}), parentArgv);
8323
- 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();
8324
8560
  }
8325
8561
  //#endregion
8326
8562
  //#region src/update-notifier.ts
@@ -8466,6 +8702,19 @@ function resolveOrg(argv, config) {
8466
8702
  return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
8467
8703
  }
8468
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
+ /**
8469
8718
  * `buildCliLogger` options derived from a loaded `holocron.config` — the
8470
8719
  * level, the Axiom dataset, and the resolved org for a keyring-backed
8471
8720
  * Axiom token. Used by every handler that has already called `loadConfig`.
@@ -8495,7 +8744,7 @@ function tokenContext(rawTokens) {
8495
8744
  return parseTokenArgs(rawTokens);
8496
8745
  } catch (err) {
8497
8746
  if (err instanceof TokenParseError) {
8498
- getLogger().error(`--token: ${err.message}`);
8747
+ reportError(`--token: ${err.message}`);
8499
8748
  process.exitCode = 1;
8500
8749
  return null;
8501
8750
  }
@@ -8512,6 +8761,10 @@ try {
8512
8761
  type: "boolean",
8513
8762
  default: false,
8514
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."
8515
8768
  }).option("token", {
8516
8769
  type: "string",
8517
8770
  array: true,
@@ -8535,6 +8788,10 @@ try {
8535
8788
  type: "boolean",
8536
8789
  default: false,
8537
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."
8538
8795
  }).middleware((argv) => {
8539
8796
  const name = argv._.slice(0, 2).join(" ") || "unknown";
8540
8797
  printRunId = Boolean(argv.debug || argv.verbose);
@@ -8543,10 +8800,9 @@ try {
8543
8800
  org: argv.org
8544
8801
  });
8545
8802
  finishCommand = startCommand(name);
8546
- }).command("clone", "Clone all repos in a GitHub org as siblings under a single directory", (y) => y.option("org", {
8803
+ }).command("clone [target]", "Clone a single repo (org/repo) or every repo in an org (org)", (y) => y.positional("target", {
8547
8804
  type: "string",
8548
- demandOption: true,
8549
- describe: "GitHub org to clone (e.g., theholocron)"
8805
+ describe: "org/repo to clone, or a bare org to clone every repo in it (confirms first)."
8550
8806
  }).option("dir", {
8551
8807
  type: "string",
8552
8808
  describe: "Parent directory to clone into (default: ~/Code/<org>)"
@@ -8557,16 +8813,39 @@ try {
8557
8813
  try {
8558
8814
  token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
8559
8815
  } catch (err) {
8560
- getLogger().error(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8816
+ reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8561
8817
  process.exitCode = 1;
8562
8818
  return;
8563
8819
  }
8564
- if ((await runClone({
8565
- org: argv.org,
8820
+ const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
8821
+ const { org, repo } = parseCloneTarget(targetInput);
8822
+ const report = await runClone({
8823
+ org,
8824
+ ...repo ? { repo } : {},
8566
8825
  token,
8567
8826
  dryRun: argv.dryRun,
8568
- ...argv.dir ? { dir: argv.dir } : {}
8569
- })).status === "fail") process.exitCode = 1;
8827
+ ...argv.dir ? { dir: argv.dir } : {},
8828
+ ...!repo ? { confirmWholeOrg: async ({ total, alreadyCloned, toClone }) => {
8829
+ if (!process.stdin.isTTY) throw new NonInteractiveError(`clone: cloning every repo in "${org}" needs interactive confirmation — run this from a terminal, or pass a specific org/repo instead`);
8830
+ if (toClone === 0) return true;
8831
+ return confirm({
8832
+ message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
8833
+ default: false
8834
+ });
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;
8570
8849
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
8571
8850
  type: "string",
8572
8851
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -8826,7 +9105,7 @@ try {
8826
9105
  if (argv.syncTrust) {
8827
9106
  const [oldFile, newFile] = argv.syncTrust;
8828
9107
  if (!oldFile || !newFile) {
8829
- 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>");
8830
9109
  process.exitCode = 1;
8831
9110
  return;
8832
9111
  }
@@ -8986,7 +9265,7 @@ try {
8986
9265
  else try {
8987
9266
  token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
8988
9267
  } catch (err) {
8989
- getLogger().error(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9268
+ reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
8990
9269
  process.exitCode = 1;
8991
9270
  return;
8992
9271
  }
@@ -9006,13 +9285,17 @@ try {
9006
9285
  }), async (argv) => {
9007
9286
  const loaded = await loadConfig(argv.cwd);
9008
9287
  applyResolvedConfig(argv, loaded.resolved);
9009
- if ((await runSyncReadme({
9288
+ const report = await runSyncReadme({
9010
9289
  loaded,
9011
9290
  context: {
9012
9291
  repoRoot: argv.cwd,
9013
9292
  dryRun: argv.dryRun
9014
9293
  }
9015
- })).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
+ }
9016
9299
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
9017
9300
  const loaded = await loadConfig(argv.cwd);
9018
9301
  console.log(JSON.stringify(loaded.resolved, null, 2));
@@ -9241,12 +9524,12 @@ try {
9241
9524
  }) === "yes";
9242
9525
  if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
9243
9526
  if (!type) {
9244
- getLogger().error("new: template type is required");
9527
+ reportError("new: template type is required");
9245
9528
  process.exitCode = 1;
9246
9529
  return;
9247
9530
  }
9248
9531
  if (!name) {
9249
- getLogger().error("new: repo name is required");
9532
+ reportError("new: repo name is required");
9250
9533
  process.exitCode = 1;
9251
9534
  return;
9252
9535
  }
@@ -9275,7 +9558,7 @@ try {
9275
9558
  })).status === "fail") process.exitCode = 1;
9276
9559
  } catch (err) {
9277
9560
  if (err instanceof NewError) {
9278
- getLogger().error(`new: ${err.message}`);
9561
+ reportError(`new: ${err.message}`);
9279
9562
  process.exitCode = 1;
9280
9563
  return;
9281
9564
  }
@@ -9334,7 +9617,7 @@ try {
9334
9617
  }).status === "fail") process.exitCode = 1;
9335
9618
  } catch (err) {
9336
9619
  if (err instanceof PluginCreateError) {
9337
- getLogger().error(`plugin create: ${err.message}`);
9620
+ reportError(`plugin create: ${err.message}`);
9338
9621
  process.exitCode = 1;
9339
9622
  return;
9340
9623
  }
@@ -9362,7 +9645,7 @@ try {
9362
9645
  extra
9363
9646
  });
9364
9647
  if (report.status === "fail") {
9365
- if (report.message) getLogger().error(`upgrade node: ${report.message}`);
9648
+ if (report.message) reportError(`upgrade node: ${report.message}`);
9366
9649
  process.exitCode = 1;
9367
9650
  }
9368
9651
  }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
@@ -9376,7 +9659,7 @@ try {
9376
9659
  pinsOnly: argv.pinsOnly
9377
9660
  });
9378
9661
  if (report.status === "fail") {
9379
- if (report.message) getLogger().error(`upgrade deps: ${report.message}`);
9662
+ if (report.message) reportError(`upgrade deps: ${report.message}`);
9380
9663
  process.exitCode = 1;
9381
9664
  }
9382
9665
  }).command("$0", false, () => {}, async (argv) => {
@@ -9402,25 +9685,25 @@ try {
9402
9685
  }).command("$0", false, () => {}, async (argv) => {
9403
9686
  await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "auth"), argv, "auth — choose a subcommand:", "Run `holocron auth --help` to see available auth subcommands.");
9404
9687
  }), () => {}).command("$0", false, () => {}, async (argv) => {
9405
- console.log(style.dim(`holocron v${CLI_VERSION}`));
9688
+ printVersionHeaderOnce();
9406
9689
  await launchMenu(COMMAND_REGISTRY.filter((e) => !e.group), argv);
9407
9690
  }).strict().help().epilogue(`Execution contexts:
9408
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) => {
9409
9692
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
9410
9693
  captureException(err);
9411
- getLogger().error(err.message);
9694
+ reportError(err.message);
9412
9695
  errorReported = true;
9413
9696
  process.exitCode = 1;
9414
9697
  return;
9415
9698
  }
9416
9699
  if (err) throw err;
9417
- getLogger().error(msg);
9700
+ reportError(msg);
9418
9701
  errorReported = true;
9419
9702
  process.exitCode = 1;
9420
9703
  }).parse();
9421
9704
  } catch (err) {
9422
9705
  captureException(err);
9423
- 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);
9424
9707
  if (!process.exitCode) process.exitCode = 1;
9425
9708
  }
9426
9709
  finishCommand(!process.exitCode);