@theholocron/cli 5.0.0-alpha.176 → 5.0.0-alpha.178

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
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, hostname, tmpdir, userInfo } from "node:os";
5
5
  import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
6
- import { checkbox, confirm, input, select } from "@inquirer/prompts";
6
+ import { checkbox, confirm, input, password, select } from "@inquirer/prompts";
7
7
  import { KNOWN_WORKFLOWS, createAlexignore, createAlexrc, createAstromech, createCommitMsg, createDependabot, createDevmoji, createEditorconfig, createEditorconfigChecker, createHeader, createLabeler, createPreCommit, createPrePush, createPrepareCommitMsg, createYamlignore, createYamllint, defaultListDirs, defaultPaths, defaultReadJson, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates, workspacePackages } from "@theholocron/astromech";
8
8
  import { loadTasksConfig } from "@theholocron/astromech/config";
9
9
  import { lintCommitMsgFile } from "@theholocron/astromech/lint";
@@ -691,7 +691,8 @@ function searchChoices(items, term) {
691
691
  return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
692
692
  name: i.name,
693
693
  value: i.value,
694
- description: i.description
694
+ description: i.description,
695
+ short: i.name.replace(/ ›$/, "")
695
696
  }));
696
697
  }
697
698
  //#endregion
@@ -750,6 +751,200 @@ async function withSpinner(label, fn) {
750
751
  }
751
752
  }
752
753
  //#endregion
754
+ //#region src/ui/prompt-options/prompt-options.ts
755
+ /**
756
+ * A required option must not resolve to an empty answer — `optionArgv`/
757
+ * `secretEnv` (child-argv.ts) both drop an empty-string value entirely,
758
+ * which would silently invoke the child without a flag the caller asked
759
+ * for. Passed as `validate` so inquirer re-prompts instead of accepting it.
760
+ */
761
+ const requireNonEmpty = (value) => value.trim().length > 0 || "required";
762
+ async function promptOne(option) {
763
+ if (option.secret || option.type === "password") return await password({
764
+ message: option.message,
765
+ ...option.required ? { validate: requireNonEmpty } : {}
766
+ });
767
+ if (option.type === "confirm") return await confirm({
768
+ message: option.message,
769
+ default: option.default
770
+ });
771
+ if (option.type === "select") {
772
+ const choices = typeof option.choices === "function" ? option.choices() : option.choices ?? [];
773
+ return await select({
774
+ message: option.message,
775
+ choices: choices.map((c) => ({ value: c }))
776
+ });
777
+ }
778
+ if (option.arity && option.arity > 1) {
779
+ const values = [];
780
+ for (let n = 0; n < option.arity; n++) values.push(await input({
781
+ message: `${option.message} (${n + 1} of ${option.arity})`,
782
+ ...option.required ? { validate: requireNonEmpty } : {}
783
+ }));
784
+ return values;
785
+ }
786
+ return await input({
787
+ message: option.message,
788
+ default: option.default,
789
+ ...option.required ? { validate: requireNonEmpty } : {}
790
+ });
791
+ }
792
+ /**
793
+ * For each of `options` not already present in `argv`, resolves a value —
794
+ * `required` ones are always included, like required positionals, never
795
+ * behind a selector; everything else is offered through one `checkbox()`
796
+ * ("set any options?") and only the picked ones are then prompted in
797
+ * `options`' own declaration order. An empty list (or every optional one
798
+ * left unpicked) means zero prompts.
799
+ *
800
+ * A picked option (whether required or checkbox-picked) with
801
+ * `askLast: true` (holocron#1052 follow-up — a time-sensitive secret like
802
+ * an OTP, which should be typed as close as possible to when it's actually
803
+ * used, or one that's always needed for a specific operator's own account
804
+ * but not everyone's) isn't given a value here yet: it's returned in
805
+ * `deferred` instead, for the caller to resolve via
806
+ * `promptForDeferredOptions` right before the real (non-dry-run) spawn.
807
+ *
808
+ * `when`, evaluated against answers collected so far in that same
809
+ * declaration order, can skip a picked option entirely. Skipped this way,
810
+ * the option is simply absent from the result, exactly as if it had never
811
+ * been picked — acceptable for a minor, genuinely optional dependency, but
812
+ * prefer `OptionMode` (see `promptForOptions`) when the options have a
813
+ * real either/or relationship, so nothing contingent is shown at all.
814
+ */
815
+ async function promptForFlatOptions(commandName, options, argv) {
816
+ if (options.length === 0) return {
817
+ answers: {},
818
+ deferred: []
819
+ };
820
+ const answers = {};
821
+ const deferred = [];
822
+ const needsPrompt = (option) => {
823
+ const existing = argv[option.key];
824
+ return existing === void 0 || existing === "";
825
+ };
826
+ const required = options.filter((o) => o.required);
827
+ const optional = options.filter((o) => !o.required);
828
+ if ((required.some(needsPrompt) || optional.some(needsPrompt)) && !process.stdin.isTTY) throw new NonInteractiveError(`\`${commandName}\` has options only settable interactively — pass them directly: holocron ${commandName} [options]`);
829
+ for (const option of required) {
830
+ if (!needsPrompt(option)) {
831
+ answers[option.key] = argv[option.key];
832
+ continue;
833
+ }
834
+ if (option.askLast) {
835
+ deferred.push(option);
836
+ continue;
837
+ }
838
+ answers[option.key] = await promptOne(option);
839
+ }
840
+ for (const option of optional) if (!needsPrompt(option)) answers[option.key] = argv[option.key];
841
+ const selectable = optional.filter(needsPrompt);
842
+ if (selectable.length === 0) return {
843
+ answers,
844
+ deferred
845
+ };
846
+ const picked = await checkbox({
847
+ message: "Set any options? (space to select, enter to skip)",
848
+ choices: selectable.map((o) => ({
849
+ name: o.message,
850
+ value: o.key
851
+ }))
852
+ });
853
+ if (picked.length === 0) return {
854
+ answers,
855
+ deferred
856
+ };
857
+ const pickedSet = new Set(picked);
858
+ for (const option of optional) {
859
+ if (!pickedSet.has(option.key)) continue;
860
+ if (option.when && !option.when(answers)) continue;
861
+ if (option.askLast) {
862
+ deferred.push(option);
863
+ continue;
864
+ }
865
+ answers[option.key] = await promptOne(option);
866
+ }
867
+ return {
868
+ answers,
869
+ deferred
870
+ };
871
+ }
872
+ /**
873
+ * For a `modes`-based command (holocron#1052 follow-up): asks which
874
+ * mutually exclusive workflow first ("What would you like to do?"), then
875
+ * offers only that mode's own options, via the exact same
876
+ * `promptForFlatOptions` flow. The picked mode's `flag`, if it has one, is
877
+ * folded in as an already-resolved `confirm`-type option so
878
+ * `buildChildArgv` renders it with no special-casing — it just sees
879
+ * another boolean option whose answer happens to already be `true`.
880
+ */
881
+ async function promptForModes(entry, modes, argv) {
882
+ if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` has options only settable interactively — pass them directly: holocron ${entry.name} [options]`);
883
+ const modeKey = await select({
884
+ message: "What would you like to do?",
885
+ choices: modes.map((m) => ({
886
+ name: m.message,
887
+ value: m.key,
888
+ description: m.description
889
+ }))
890
+ });
891
+ const mode = modes.find((m) => m.key === modeKey);
892
+ const options = mode.options ?? [];
893
+ const { answers, deferred } = await promptForFlatOptions(entry.name, options, argv);
894
+ if (!mode.flag) return {
895
+ options,
896
+ answers,
897
+ deferred
898
+ };
899
+ const flagOption = {
900
+ key: `__mode_${mode.key}`,
901
+ flag: mode.flag,
902
+ type: "confirm",
903
+ message: mode.message
904
+ };
905
+ return {
906
+ options: [flagOption, ...options],
907
+ answers: {
908
+ [flagOption.key]: true,
909
+ ...answers
910
+ },
911
+ deferred
912
+ };
913
+ }
914
+ /**
915
+ * Resolves `entry`'s options into a {@link ResolvedOptions} ready for
916
+ * `buildChildArgv`/`secretEnv` — delegating to {@link promptForModes} when
917
+ * `entry.modes` is set, or treating `entry.options` as a flat list
918
+ * otherwise. See `promptForFlatOptions` for the flat-list prompting
919
+ * algorithm itself. `result.deferred` still needs
920
+ * {@link promptForDeferredOptions} before the real spawn.
921
+ */
922
+ async function promptForOptions(entry, argv) {
923
+ if (entry.modes && entry.modes.length > 0) return promptForModes(entry, entry.modes, argv);
924
+ const options = entry.options ?? [];
925
+ const { answers, deferred } = await promptForFlatOptions(entry.name, options, argv);
926
+ return {
927
+ options,
928
+ answers,
929
+ deferred
930
+ };
931
+ }
932
+ /**
933
+ * Collects a value for each of `deferred` — the `askLast: true` options
934
+ * `promptForOptions` deliberately left unresolved. Call this as the very
935
+ * last thing before the real (non-dry-run) spawn, never before: a dry-run
936
+ * preview never reaches the real npm call these options exist for (an OTP
937
+ * is never checked during `--dry-run`), and a time-sensitive code typed
938
+ * any earlier — before a dry-run preview runs, before "proceed with the
939
+ * real run?" waits on a human — risks going stale before it's actually
940
+ * used. A no-op (no prompt at all) when `deferred` is empty.
941
+ */
942
+ async function promptForDeferredOptions(deferred) {
943
+ const answers = {};
944
+ for (const option of deferred) answers[option.key] = await promptOne(option);
945
+ return answers;
946
+ }
947
+ //#endregion
753
948
  //#region src/ui/prompt-positionals/prompt-positionals.ts
754
949
  /**
755
950
  * For each of `entry.positionals` not already present in `argv`, prompts for
@@ -2257,7 +2452,7 @@ async function runDoctor(input) {
2257
2452
  capability: row.capability,
2258
2453
  provider: row.provider,
2259
2454
  status: row.status,
2260
- detail: row.message
2455
+ detail: row.logMessage ?? row.message
2261
2456
  }, `doctor: ${row.capability}`);
2262
2457
  print(statusRow(row.status, [`${pad(row.capability, 14)} via ${pad(row.provider, 14)}`, row.message]));
2263
2458
  }
@@ -2296,8 +2491,33 @@ async function smokeCheck(key, provider, impl) {
2296
2491
  try {
2297
2492
  return await withTimeout(runSmokeCheck(key, provider, impl), SMOKE_CHECK_TIMEOUT_MS);
2298
2493
  } catch (err) {
2299
- return mk(key, provider, "fail", errorMessage(err));
2494
+ return mk(key, provider, "fail", smokeCheckErrorMessage(err), errorMessage(err));
2495
+ }
2496
+ }
2497
+ /** Vendor response bodies land in the terminal — cap their length so one
2498
+ * oversized error can't flood the report, and strip control characters
2499
+ * (newlines included) so a crafted body can't forge extra report lines
2500
+ * or inject terminal escape sequences. */
2501
+ const MAX_DETAIL_LENGTH = 200;
2502
+ function sanitizeDetail(text) {
2503
+ const stripped = text.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
2504
+ return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
2505
+ }
2506
+ /**
2507
+ * `errorMessage(err)` alone is just `"<Vendor> <METHOD> <path> → <status>"` —
2508
+ * the actual reason (what the vendor's response body said) lives in
2509
+ * `ProviderApiError.details`, a raw response-body string (not parsed JSON —
2510
+ * see AGENTS.md), and was being silently dropped. Appending it (sanitized
2511
+ * and length-capped — see `sanitizeDetail`) turns e.g. a bare "→ 400" into
2512
+ * something that actually explains itself, without the vendor text being
2513
+ * able to overwhelm or manipulate the terminal report.
2514
+ */
2515
+ function smokeCheckErrorMessage(err) {
2516
+ if (err instanceof ProviderApiError$1 && err.details) {
2517
+ const detail = sanitizeDetail(String(err.details));
2518
+ if (detail) return `${errorMessage(err)}: ${detail}`;
2300
2519
  }
2520
+ return errorMessage(err);
2301
2521
  }
2302
2522
  async function runSmokeCheck(key, provider, impl) {
2303
2523
  switch (key) {
@@ -2365,12 +2585,13 @@ async function runSmokeCheck(key, provider, impl) {
2365
2585
  function countResolvedLifecycle(report) {
2366
2586
  return `${report.lifecycle.filter((l) => l.resolved).length}/${report.lifecycle.length} lifecycle slots ready`;
2367
2587
  }
2368
- function mk(capability, provider, status, message) {
2588
+ function mk(capability, provider, status, message, logMessage) {
2369
2589
  return {
2370
2590
  capability,
2371
2591
  provider,
2372
2592
  status,
2373
- message
2593
+ message,
2594
+ ...logMessage !== void 0 ? { logMessage } : {}
2374
2595
  };
2375
2596
  }
2376
2597
  //#endregion
@@ -4155,8 +4376,22 @@ function printNextSteps$1(print, inputs) {
4155
4376
  print(" 7. Commit + push when capability is functionally complete.");
4156
4377
  }
4157
4378
  //#endregion
4379
+ //#region src/commands/publish/resolve-otp.ts
4380
+ /**
4381
+ * `argvOtp` if given, else the `HOLOCRON_OTP` env var — the secret-delivery
4382
+ * side of the interactive menu's options prompt (holocron#1052): a
4383
+ * menu-launched `package publish` delivers `--otp` through this env var
4384
+ * instead of argv, since `ps` and shell history both expose a child's argv
4385
+ * but never its env. A direct `holocron package publish --otp <code>`
4386
+ * invocation is unaffected; the flag always wins when both are present.
4387
+ */
4388
+ function resolveOtp(argvOtp, env) {
4389
+ return argvOtp ?? env.get("HOLOCRON_OTP");
4390
+ }
4391
+ //#endregion
4158
4392
  //#region src/commands/publish/default-exec.ts
4159
4393
  const defaultExec$1 = async (cmd, args, opts) => {
4394
+ const { HOLOCRON_OTP: _omittedOtp, ...env } = process.env;
4160
4395
  const result = spawnSync(cmd, args, {
4161
4396
  cwd: opts.cwd,
4162
4397
  encoding: "utf8",
@@ -4164,7 +4399,8 @@ const defaultExec$1 = async (cmd, args, opts) => {
4164
4399
  "inherit",
4165
4400
  "pipe",
4166
4401
  "pipe"
4167
- ]
4402
+ ],
4403
+ env
4168
4404
  });
4169
4405
  return {
4170
4406
  exitCode: result.status ?? -1,
@@ -4218,46 +4454,117 @@ function discoverManifests(cwd) {
4218
4454
  }));
4219
4455
  }
4220
4456
  //#endregion
4221
- //#region src/commands/publish/discover-public-packages.ts
4222
- /** The names of `discoverPublicPackageEntries`' packages, without requiring a version. */
4223
- function discoverPublicPackages(cwd) {
4224
- return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
4457
+ //#region src/commands/publish/discover-public-package-entries.ts
4458
+ /**
4459
+ * Every public package (`!private && name`) with its version and a
4460
+ * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4461
+ * mode needs both to check `npm view <pkg>@<version>` and publish one
4462
+ * workspace at a time.
4463
+ */
4464
+ function discoverPublicPackageEntries(cwd) {
4465
+ return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4466
+ name: manifest.name,
4467
+ version: manifest.version,
4468
+ dir
4469
+ }] : []);
4470
+ }
4471
+ //#endregion
4472
+ //#region src/commands/publish/filter-new-packages.ts
4473
+ /**
4474
+ * Keeps only the entries that don't exist on npm yet — the actual
4475
+ * chicken-and-egg case `--initial` exists for (ADR: npm requires a package
4476
+ * to exist before Trusted Publishing can be configured on it). Once a name
4477
+ * exists on npm at all, regardless of version, Trusted Publishing can
4478
+ * already be configured for it and every later version goes through the
4479
+ * steady-state publish instead — re-running `--initial` against it would
4480
+ * just hit a stale `pnpm publish` with no `--skip-already-published` safety
4481
+ * net. Existence, not version: unlike the steady-state publish's own
4482
+ * already-published check, this intentionally ignores the local version —
4483
+ * a package that exists on npm at an older version is still not `--initial`'s
4484
+ * problem.
4485
+ *
4486
+ * Prints one `statusRow("skip", …)` line per already-published entry as it
4487
+ * finds them — same shape `clone` uses for an already-cloned repo, with the
4488
+ * registry's own version (not the local one) in the message, since that's
4489
+ * what "already published" actually means — so the caller's own summary
4490
+ * line is the only other thing it needs to print. `nameWidth`/`dirWidth`
4491
+ * (computed by the caller across every discovered entry, not just the ones
4492
+ * landing here) keep these rows column-aligned with the "would
4493
+ * publish"/"published" rows the caller prints afterward for the entries
4494
+ * that come back from this function — the two sets only read as one table
4495
+ * when both pad to the same widths.
4496
+ */
4497
+ async function filterNewPackages(entries, cwd, exec, print, nameWidth, dirWidth) {
4498
+ const results = [];
4499
+ for (const entry of entries) {
4500
+ const view = await exec("npm", [
4501
+ "view",
4502
+ entry.name,
4503
+ "version"
4504
+ ], { cwd });
4505
+ const publishedVersion = view.exitCode === 0 ? view.stdout.trim() : "";
4506
+ if (publishedVersion) {
4507
+ print(statusRow("skip", [
4508
+ pad(entry.name, nameWidth),
4509
+ pad(entry.dir, dirWidth),
4510
+ `already published (v${publishedVersion})`
4511
+ ]));
4512
+ continue;
4513
+ }
4514
+ results.push(entry);
4515
+ }
4516
+ return results;
4225
4517
  }
4226
4518
  //#endregion
4227
4519
  //#region src/commands/publish/print-next-steps.ts
4228
- function printNextSteps(print, env, packageNames, repoName) {
4520
+ function printNextSteps(print, env, packageNames, identity) {
4521
+ print("");
4522
+ print("→ next: configure Trusted Publisher for each package on npm:");
4523
+ print(" Publisher: GitHub Actions");
4524
+ print(" Label: Holocron Release");
4525
+ print(` Organization: ${identity.owner}`);
4526
+ print(` Repository: ${identity.repo}`);
4527
+ print(" Workflow filename: delivery.publish.yml");
4229
4528
  print("");
4230
- print(" → next: configure Trusted Publisher for each package on npm:");
4529
+ print("→ packages to be published:");
4231
4530
  for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
4232
- print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
4233
4531
  if (env.get("NPM_TOKEN")) {
4234
4532
  print("");
4235
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
4533
+ print("→ cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
4236
4534
  print(" https://www.npmjs.com/settings/~/tokens");
4237
4535
  }
4238
4536
  }
4239
4537
  //#endregion
4240
- //#region src/commands/publish/resolve-repo-name.ts
4241
- async function resolveRepoName(cwd, exec) {
4242
- return (await readGitRemote(cwd, { exec: async (args, options) => {
4243
- const result = await exec("git", args, options);
4244
- if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4245
- return result.stdout;
4246
- } }))?.repo ?? "unknown";
4538
+ //#region src/commands/publish/redact-otp.ts
4539
+ /**
4540
+ * Replaces the literal `otp` value in a "would run: pnpm ..." dry-run
4541
+ * preview line with `***` — the real `args` passed to `exec()` still need
4542
+ * the raw OTP (npm's own `--otp` flag has no env-var alternative), but the
4543
+ * preview is printed straight to the terminal / captured as dry-run output
4544
+ * and must never echo a live 2FA code back out.
4545
+ */
4546
+ function redactOtp(args, otp) {
4547
+ if (!otp) return [...args];
4548
+ return args.map((arg) => arg === otp ? "***" : arg);
4247
4549
  }
4248
4550
  //#endregion
4249
- //#region src/commands/publish/workspace-filter-args.ts
4551
+ //#region src/commands/publish/resolve-repo-identity.ts
4250
4552
  /**
4251
- * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4252
- * roots exist. Empty (single-package repo, no `-r`) when neither does.
4253
- * Shared by every `pnpm publish` invocation below so a repo with only
4254
- * `plugins/*` (or both) is covered the same as `packages/*`-only.
4553
+ * One git-remote read for both the org and repo shown in the Trusted
4554
+ * Publisher next-steps output — `readGitRemote` already parses out `owner`
4555
+ * alongside `repo`, so there's no reason to shell out to git twice for the
4556
+ * two halves of the same remote URL.
4255
4557
  */
4256
- function workspaceFilterArgs(cwd) {
4257
- const filters = [];
4258
- if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4259
- if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4260
- return filters.length > 0 ? ["-r", ...filters] : [];
4558
+ async function resolveRepoIdentity(cwd, exec) {
4559
+ const remote = await readGitRemote(cwd, { exec: async (args, options) => {
4560
+ const result = await exec("git", args, options);
4561
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4562
+ return result.stdout;
4563
+ } });
4564
+ return {
4565
+ owner: remote?.owner ?? "unknown",
4566
+ repo: remote?.repo ?? "unknown"
4567
+ };
4261
4568
  }
4262
4569
  //#endregion
4263
4570
  //#region src/commands/publish/run-publish/run-publish.ts
@@ -4287,10 +4594,31 @@ function workspaceFilterArgs(cwd) {
4287
4594
  * outside the semantic-release/OIDC steady state) isn't built yet; the flag
4288
4595
  * exists so the surface doesn't need another rename when it is.
4289
4596
  *
4597
+ * Self-narrowing: a package that already exists on npm (any version) isn't
4598
+ * the chicken-and-egg case anymore — Trusted Publishing can already be
4599
+ * configured for it, and every later version goes through the steady-state
4600
+ * publish instead. So every publicly publishable package on disk is checked
4601
+ * against the registry first, and only the ones that don't exist yet are
4602
+ * actually targeted. Re-running `--initial` after adding one new package to
4603
+ * an already-bootstrapped monorepo publishes just that one. Nothing left to
4604
+ * bootstrap is reported as a success, not a failure.
4605
+ *
4290
4606
  * If `NPM_TOKEN` is detected in env, the command prints a final
4291
4607
  * "revoke this token at <url>" reminder — same pattern as `rando vc
4292
4608
  * setup` for the ephemeral GH admin PAT.
4293
4609
  */
4610
+ /**
4611
+ * The same `N <verb>, N failed, N skipped` shape `clone` ends on, styled the
4612
+ * same way (dim for a dry-run preview, success/fail for the real thing) —
4613
+ * `--initial` publishes in one atomic `pnpm publish` call rather than
4614
+ * `clone`'s per-repo loop, so `failedCount` is always either 0 or every
4615
+ * targeted package at once, never a partial count.
4616
+ */
4617
+ function printSummary(print, dryRun, publishedCount, failedCount, skippedCount) {
4618
+ const summary = `${publishedCount} published, ${failedCount} failed, ${skippedCount} skipped`;
4619
+ print("");
4620
+ print(dryRun ? style.dim(` dry-run: ${summary}`) : failedCount > 0 ? style.fail(` ${summary}`) : style.success(` ${summary}`));
4621
+ }
4294
4622
  async function runPublish(input = {}) {
4295
4623
  const print = input.print ?? ((line) => console.log(line));
4296
4624
  const logger = input.logger ?? getLogger();
@@ -4301,26 +4629,12 @@ async function runPublish(input = {}) {
4301
4629
  const env = makeEnv(input.env);
4302
4630
  const exec = input.exec ?? defaultExec$1;
4303
4631
  const login = input.login ?? defaultLogin;
4304
- const publishArgs = [
4305
- ...workspaceFilterArgs(cwd),
4306
- "publish",
4307
- "--access",
4308
- "public",
4309
- "--no-git-checks",
4310
- "--tag",
4311
- tag,
4312
- ...otp ? ["--otp", otp] : []
4313
- ];
4314
- print(`Holocron publish --initial${dryRun ? " (dry-run)" : ""}`);
4315
- print(` cwd: ${cwd}`);
4316
- print(` tag: ${tag}`);
4317
4632
  logger.info({
4318
4633
  tag,
4319
4634
  dryRun: dryRun || void 0
4320
4635
  }, "publish --initial: start");
4321
- if (otp) print(` otp: <${otp.length} chars>`);
4322
4636
  print("");
4323
- print(" → verifying npm auth (`npm whoami`)…");
4637
+ print(style.dim(" → verifying npm auth (`npm whoami`)…"));
4324
4638
  let whoami = await exec("npm", ["whoami"], { cwd });
4325
4639
  if (whoami.exitCode !== 0) {
4326
4640
  print(" not authenticated — running `npm login --auth-type=web`…");
@@ -4328,7 +4642,7 @@ async function runPublish(input = {}) {
4328
4642
  if ((await login(cwd)).exitCode !== 0) {
4329
4643
  const message = "npm login failed. Run `npm login --auth-type=web` (or `npm login`) manually, then re-run.";
4330
4644
  print(` ✗ ${message}`);
4331
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4645
+ const packageNames = (input.packages ?? discoverPublicPackageEntries(cwd)).map((e) => e.name);
4332
4646
  logger.warn({ reason: "npm login failed" }, "publish --initial: done");
4333
4647
  return {
4334
4648
  status: "fail",
@@ -4340,7 +4654,7 @@ async function runPublish(input = {}) {
4340
4654
  if (whoami.exitCode !== 0) {
4341
4655
  const message = "npm login completed but `npm whoami` still fails — check your npm account.";
4342
4656
  print(` ✗ ${message}`);
4343
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4657
+ const packageNames = (input.packages ?? discoverPublicPackageEntries(cwd)).map((e) => e.name);
4344
4658
  logger.warn({ reason: "npm whoami still fails after login" }, "publish --initial: done");
4345
4659
  return {
4346
4660
  status: "fail",
@@ -4349,10 +4663,35 @@ async function runPublish(input = {}) {
4349
4663
  };
4350
4664
  }
4351
4665
  }
4352
- print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
4353
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4354
- const repoName = input.repoName ?? await resolveRepoName(cwd, exec);
4666
+ const whoamiName = whoami.stdout.trim() || "<unknown>";
4667
+ print(style.dim(` ✓ authed as ${whoamiName}`));
4668
+ print("");
4669
+ if (otp) print(style.dim(` otp: <${otp.length} chars>`));
4670
+ const discoveredEntries = input.packages ?? discoverPublicPackageEntries(cwd);
4671
+ const nameWidth = Math.max(0, ...discoveredEntries.map((e) => e.name.length));
4672
+ const dirWidth = Math.max(0, ...discoveredEntries.map((e) => e.dir.length));
4673
+ const newEntries = input.packages ? [...discoveredEntries] : await filterNewPackages(discoveredEntries, cwd, exec, print, nameWidth, dirWidth);
4674
+ const packageNames = newEntries.map((e) => e.name);
4675
+ const skippedCount = input.packages ? 0 : discoveredEntries.length - newEntries.length;
4676
+ const resolvedIdentity = input.repoName !== void 0 && input.orgName !== void 0 ? {
4677
+ owner: input.orgName,
4678
+ repo: input.repoName
4679
+ } : await resolveRepoIdentity(cwd, exec);
4680
+ const identity = {
4681
+ owner: input.orgName ?? resolvedIdentity.owner,
4682
+ repo: input.repoName ?? resolvedIdentity.repo
4683
+ };
4355
4684
  if (packageNames.length === 0) {
4685
+ if (input.packages === void 0 && discoveredEntries.length > 0) {
4686
+ const message = "nothing to publish — every public package already exists on npm; nothing new to bootstrap";
4687
+ printSummary(print, dryRun, 0, 0, skippedCount);
4688
+ logger.info({ reason: message }, "publish --initial: done");
4689
+ return {
4690
+ status: "ok",
4691
+ message,
4692
+ packageNames
4693
+ };
4694
+ }
4356
4695
  const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
4357
4696
  print(` ✗ ${message}`);
4358
4697
  logger.warn({ reason: message }, "publish --initial: done");
@@ -4362,11 +4701,22 @@ async function runPublish(input = {}) {
4362
4701
  packageNames
4363
4702
  };
4364
4703
  }
4704
+ const publishArgs = [
4705
+ ...hasPackagesDir(cwd) || hasPluginsDir(cwd) ? ["-r", ...packageNames.map((name) => `--filter=${name}`)] : [],
4706
+ "publish",
4707
+ "--access",
4708
+ "public",
4709
+ "--no-git-checks",
4710
+ "--tag",
4711
+ tag,
4712
+ ...otp ? ["--otp", otp] : []
4713
+ ];
4365
4714
  if (dryRun) {
4366
4715
  print("");
4367
- print(" … (dry-run) skipping actual publish");
4368
- print(` would run: pnpm ${publishArgs.join(" ")}`);
4369
- printNextSteps(print, env, packageNames, repoName);
4716
+ for (const entry of newEntries) print(style.dim(` ${pad(entry.name, nameWidth)} ${pad(entry.dir, dirWidth)} would publish (v${entry.version})`));
4717
+ print(style.dim(` would run: pnpm ${redactOtp(publishArgs, otp).join(" ")}`));
4718
+ printSummary(print, dryRun, packageNames.length, 0, skippedCount);
4719
+ printNextSteps(print, env, packageNames, identity);
4370
4720
  logger.info({
4371
4721
  tag,
4372
4722
  status: "dry-run",
@@ -4379,7 +4729,7 @@ async function runPublish(input = {}) {
4379
4729
  };
4380
4730
  }
4381
4731
  print("");
4382
- print(" → publishing all public @theholocron/* packages…");
4732
+ print(` → publishing ${packageNames.length} new package${packageNames.length === 1 ? "" : "s"}…`);
4383
4733
  const publish = await exec("pnpm", publishArgs, { cwd });
4384
4734
  if (publish.exitCode !== 0) {
4385
4735
  const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
@@ -4389,6 +4739,12 @@ async function runPublish(input = {}) {
4389
4739
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
4390
4740
  print(` pnpm exec tsx packages/cli/src/cli.ts package publish --initial --otp <6-digit-code>`);
4391
4741
  }
4742
+ for (const entry of newEntries) print(statusRow("fail", [
4743
+ pad(entry.name, nameWidth),
4744
+ pad(entry.dir, dirWidth),
4745
+ "publish failed"
4746
+ ]));
4747
+ printSummary(print, dryRun, 0, packageNames.length, skippedCount);
4392
4748
  logger.warn({
4393
4749
  tag,
4394
4750
  reason: message
@@ -4399,8 +4755,13 @@ async function runPublish(input = {}) {
4399
4755
  packageNames
4400
4756
  };
4401
4757
  }
4402
- print(" ✓ publish complete");
4403
- printNextSteps(print, env, packageNames, repoName);
4758
+ for (const entry of newEntries) print(statusRow("ok", [
4759
+ pad(entry.name, nameWidth),
4760
+ pad(entry.dir, dirWidth),
4761
+ `published (v${entry.version})`
4762
+ ]));
4763
+ printSummary(print, dryRun, packageNames.length, 0, skippedCount);
4764
+ printNextSteps(print, env, packageNames, identity);
4404
4765
  logger.info({
4405
4766
  tag,
4406
4767
  status: "ok",
@@ -4412,19 +4773,18 @@ async function runPublish(input = {}) {
4412
4773
  };
4413
4774
  }
4414
4775
  //#endregion
4415
- //#region src/commands/publish/discover-public-package-entries.ts
4776
+ //#region src/commands/publish/workspace-filter-args.ts
4416
4777
  /**
4417
- * Every public package (`!private && name`) with its version and a
4418
- * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4419
- * mode needs both to check `npm view <pkg>@<version>` and publish one
4420
- * workspace at a time.
4778
+ * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4779
+ * roots exist. Empty (single-package repo, no `-r`) when neither does.
4780
+ * Shared by every `pnpm publish` invocation below so a repo with only
4781
+ * `plugins/*` (or both) is covered the same as `packages/*`-only.
4421
4782
  */
4422
- function discoverPublicPackageEntries(cwd) {
4423
- return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4424
- name: manifest.name,
4425
- version: manifest.version,
4426
- dir
4427
- }] : []);
4783
+ function workspaceFilterArgs(cwd) {
4784
+ const filters = [];
4785
+ if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4786
+ if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4787
+ return filters.length > 0 ? ["-r", ...filters] : [];
4428
4788
  }
4429
4789
  //#endregion
4430
4790
  //#region src/commands/publish/run-steady-publish/run-steady-publish.ts
@@ -4498,7 +4858,7 @@ async function runSteadyPublish(input = {}) {
4498
4858
  ...otpFlags
4499
4859
  ];
4500
4860
  print(` … (dry-run) skipping actual publish`);
4501
- print(` would run: pnpm ${args.join(" ")}`);
4861
+ print(` would run: pnpm ${redactOtp(args, otp).join(" ")}`);
4502
4862
  }
4503
4863
  logger.info({
4504
4864
  tag,
@@ -4531,6 +4891,15 @@ async function runSteadyPublish(input = {}) {
4531
4891
  ...otpFlags
4532
4892
  ], { cwd });
4533
4893
  if (publish.exitCode !== 0) {
4894
+ const recheck = await exec("npm", [
4895
+ "view",
4896
+ `${entry.name}@${entry.version}`,
4897
+ "version"
4898
+ ], { cwd });
4899
+ if (recheck.exitCode === 0 && recheck.stdout.trim()) {
4900
+ print(` skip ${entry.name}@${entry.version} (published concurrently by another run)`);
4901
+ continue;
4902
+ }
4534
4903
  const message = `publish failed for ${entry.name}@${entry.version} (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
4535
4904
  print(` ✗ ${message}`);
4536
4905
  logger.warn({
@@ -4593,6 +4962,12 @@ async function runSteadyPublish(input = {}) {
4593
4962
  };
4594
4963
  }
4595
4964
  //#endregion
4965
+ //#region src/commands/publish/discover-public-packages.ts
4966
+ /** The names of `discoverPublicPackageEntries`' packages, without requiring a version. */
4967
+ function discoverPublicPackages(cwd) {
4968
+ return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
4969
+ }
4970
+ //#endregion
4596
4971
  //#region src/commands/publish/is-eotp.ts
4597
4972
  function isEotp(parsed) {
4598
4973
  return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
@@ -8788,6 +9163,18 @@ async function runUpgradeNode(input) {
8788
9163
  //#endregion
8789
9164
  //#region src/interactive-menu/child-argv/child-argv.ts
8790
9165
  /**
9166
+ * Whether `option`'s answer must be delivered via env, never argv — true for
9167
+ * an explicit `secret: true`, and also for any `type: "password"` option
9168
+ * even if `secret` was left unset. `promptOne` (prompt-options.ts) already
9169
+ * treats these two as interchangeable triggers for masking the input
9170
+ * itself; `buildChildArgv`/`secretEnv` need to agree, or a password-typed
9171
+ * option that forgot to also set `secret: true` would mask the keystrokes
9172
+ * on screen while still leaking the typed value into the child's argv.
9173
+ */
9174
+ function isSecretOption(option) {
9175
+ return Boolean(option.secret) || option.type === "password";
9176
+ }
9177
+ /**
8791
9178
  * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
8792
9179
  * menu-launched child should inherit from the parent invocation. Everything
8793
9180
  * else (command-specific options) was never captured by the top-level `$0`
@@ -8807,17 +9194,78 @@ function forwardedFlags(argv, dryRunOverride) {
8807
9194
  if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8808
9195
  return out;
8809
9196
  }
8810
- /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8811
- function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
9197
+ /**
9198
+ * `option`'s own argv tokens for `value` (never called for a `secret`
9199
+ * option — those are delivered via {@link secretEnv} instead, never argv).
9200
+ *
9201
+ * - boolean `true` → `--flag`; `false` → `--no-flag` when the yargs
9202
+ * default is `true` (`negatable: true` — answering "no" must actively
9203
+ * override it, not just omit the flag and silently keep the default),
9204
+ * otherwise omitted (the yargs default is already `false`).
9205
+ * - string/select → `--flag value`, omitted when empty.
9206
+ * - `arity`-valued array → `--flag v1 v2 ...`.
9207
+ */
9208
+ function optionArgv(option, value) {
9209
+ if (value === void 0) return [];
9210
+ if (typeof value === "boolean") {
9211
+ if (value) return [option.flag];
9212
+ return option.negatable ? [`--no-${option.flag.replace(/^--/, "")}`] : [];
9213
+ }
9214
+ if (Array.isArray(value)) return value.length === 0 ? [] : [option.flag, ...value];
9215
+ return value === "" ? [] : [option.flag, value];
9216
+ }
9217
+ /**
9218
+ * `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved
9219
+ * positionals, then non-secret option flags, then forwarded flags, appended
9220
+ * in that order. `resolved` is whatever {@link promptForOptions} returned —
9221
+ * for a `modes`-based command, that's already the picked mode's own options
9222
+ * (plus a synthetic entry for its `flag`, if any), so this renders either
9223
+ * shape identically with no need to know which kind of command it came from.
9224
+ */
9225
+ function buildChildArgv(entry, positionals, resolved, parentArgv, dryRunOverride) {
9226
+ const optionArgs = resolved.options.flatMap((option) => isSecretOption(option) ? [] : optionArgv(option, resolved.answers[option.key]));
8812
9227
  return [
8813
9228
  ...entry.spawnArgs ?? entry.name.split(" "),
8814
9229
  ...positionals,
9230
+ ...optionArgs,
8815
9231
  ...forwardedFlags(parentArgv, dryRunOverride)
8816
9232
  ];
8817
9233
  }
9234
+ /**
9235
+ * Secret option answers, keyed by the env var their handler reads instead
9236
+ * of argv (`ps` and shell history expose argv, never a child's own env) —
9237
+ * `HOLOCRON_<KEY_IN_SCREAMING_SNAKE_CASE>`, e.g. `otp` → `HOLOCRON_OTP`.
9238
+ * Merged into {@link spawnChild}'s `extraEnv` alongside `NO_UPDATE_NOTIFIER`
9239
+ * and (for a preview spawn) `DRY_RUN_PREVIEW_ENV_VAR`.
9240
+ */
9241
+ function secretEnv(resolved) {
9242
+ const out = {};
9243
+ for (const option of resolved.options) {
9244
+ if (!isSecretOption(option)) continue;
9245
+ const value = resolved.answers[option.key];
9246
+ if (typeof value !== "string" || value === "") continue;
9247
+ out[`HOLOCRON_${option.key.replace(/([A-Z])/g, "_$1").toUpperCase()}`] = value;
9248
+ }
9249
+ return out;
9250
+ }
8818
9251
  //#endregion
8819
9252
  //#region src/interactive-menu/command-registry/command-registry.ts
8820
9253
  const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
9254
+ const PUBLISH_TAG_OPTION = {
9255
+ key: "tag",
9256
+ flag: "--tag",
9257
+ type: "input",
9258
+ message: "npm dist-tag (blank = alpha for a bootstrap publish, latest otherwise):"
9259
+ };
9260
+ const PUBLISH_OTP_OPTION = {
9261
+ key: "otp",
9262
+ flag: "--otp",
9263
+ type: "password",
9264
+ secret: true,
9265
+ askLast: true,
9266
+ required: Boolean(env.get("HOLOCRON_NPM_ENFORCE_2FA")),
9267
+ message: "One-time password (2FA), if npm requires it for this publish:"
9268
+ };
8821
9269
  /**
8822
9270
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
8823
9271
  * yargs registrations (this module does not introspect yargs at runtime).
@@ -8928,7 +9376,56 @@ const COMMAND_REGISTRY = [
8928
9376
  name: "package publish",
8929
9377
  description: "Publish @theholocron/* packages to npm",
8930
9378
  positionals: [],
8931
- supportsDryRun: true
9379
+ supportsDryRun: true,
9380
+ modes: [
9381
+ {
9382
+ key: "steady",
9383
+ message: "Publish a new version",
9384
+ options: [
9385
+ PUBLISH_TAG_OPTION,
9386
+ PUBLISH_OTP_OPTION,
9387
+ {
9388
+ key: "provenance",
9389
+ flag: "--provenance",
9390
+ type: "confirm",
9391
+ default: true,
9392
+ negatable: true,
9393
+ message: "Publish with --provenance? (on by default)"
9394
+ },
9395
+ {
9396
+ key: "skipAlreadyPublished",
9397
+ flag: "--skip-already-published",
9398
+ type: "confirm",
9399
+ default: false,
9400
+ message: "Skip a package already published at this exact version, instead of one bulk publish?"
9401
+ }
9402
+ ]
9403
+ },
9404
+ {
9405
+ key: "initial",
9406
+ message: "Initial publish (--initial)",
9407
+ description: "Use only for a package's very first publish.",
9408
+ flag: "--initial",
9409
+ options: [PUBLISH_TAG_OPTION, PUBLISH_OTP_OPTION]
9410
+ },
9411
+ {
9412
+ key: "syncTrust",
9413
+ message: "Sync Trusted Publisher workflow filename across every public package (--sync-trust)",
9414
+ options: [{
9415
+ key: "syncTrust",
9416
+ flag: "--sync-trust",
9417
+ type: "input",
9418
+ arity: 2,
9419
+ required: true,
9420
+ message: "Workflow filename (old, then new)"
9421
+ }, {
9422
+ key: "repo",
9423
+ flag: "--repo",
9424
+ type: "input",
9425
+ message: "owner/repo for Trusted Publisher config (blank = theholocron/holocron):"
9426
+ }]
9427
+ }
9428
+ ]
8932
9429
  },
8933
9430
  {
8934
9431
  name: "repo sync",
@@ -9150,6 +9647,25 @@ function spawnChild(args, extraEnv) {
9150
9647
  });
9151
9648
  }
9152
9649
  /**
9650
+ * Collects `resolved.deferred`'s values (holocron#1052 follow-up) and folds
9651
+ * them into `resolved.answers` — call this once, right before building the
9652
+ * real (non-dry-run) spawn's argv, never any earlier: a dry-run preview and
9653
+ * a human confirming "proceed with the real run?" can both take a while,
9654
+ * and a time-sensitive secret (an OTP) typed before either risks going
9655
+ * stale before it's actually used. A no-op when nothing was deferred.
9656
+ */
9657
+ async function resolveForRealSpawn(resolved) {
9658
+ if (!resolved.deferred || resolved.deferred.length === 0) return resolved;
9659
+ const deferredAnswers = await promptForDeferredOptions(resolved.deferred);
9660
+ return {
9661
+ ...resolved,
9662
+ answers: {
9663
+ ...resolved.answers,
9664
+ ...deferredAnswers
9665
+ }
9666
+ };
9667
+ }
9668
+ /**
9153
9669
  * Whether `launchMenu` should auto-preview a picked command before running
9154
9670
  * it for real — only when the command's handler actually honours
9155
9671
  * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
@@ -9202,8 +9718,13 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
9202
9718
  else picked = entries.find((e) => e.name === value);
9203
9719
  }
9204
9720
  const positionals = await promptForPositionals(picked, {});
9721
+ const resolvedOptions = await promptForOptions(picked, {});
9722
+ const extraEnv = secretEnv(resolvedOptions);
9205
9723
  if (shouldPreviewDryRun(picked, parentArgv)) {
9206
- const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
9724
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, resolvedOptions, parentArgv, true), {
9725
+ [DRY_RUN_PREVIEW_ENV_VAR]: "1",
9726
+ ...extraEnv
9727
+ });
9207
9728
  if (dryExit === 2) {
9208
9729
  process.exitCode = 0;
9209
9730
  return;
@@ -9219,10 +9740,12 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
9219
9740
  process.exitCode = 0;
9220
9741
  return;
9221
9742
  }
9222
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
9743
+ const forReal = await resolveForRealSpawn(resolvedOptions);
9744
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, forReal, parentArgv, false), secretEnv(forReal));
9223
9745
  return;
9224
9746
  }
9225
- process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
9747
+ const forReal = await resolveForRealSpawn(resolvedOptions);
9748
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, forReal, parentArgv), secretEnv(forReal));
9226
9749
  }
9227
9750
  //#endregion
9228
9751
  //#region src/interactive-menu/run-menu-entries/run-menu-entries.ts
@@ -10357,6 +10880,7 @@ try {
10357
10880
  default: false,
10358
10881
  describe: "Steady-state only: check `npm view <pkg>@<version>` before publishing each package and skip it if that exact version already exists, instead of one bulk publish. For repos that ship many independently-versioned packages where a release doesn't bump every one."
10359
10882
  }), async (argv) => {
10883
+ const otp = resolveOtp(argv.otp, env);
10360
10884
  if (argv.syncTrust) {
10361
10885
  const [oldFile, newFile] = argv.syncTrust;
10362
10886
  if (!oldFile || !newFile) {
@@ -10374,12 +10898,14 @@ try {
10374
10898
  return;
10375
10899
  }
10376
10900
  if (argv.initial) {
10377
- if ((await runPublish({
10901
+ const report = await runPublish({
10378
10902
  cwd: argv.cwd,
10379
10903
  dryRun: argv.dryRun,
10380
10904
  ...argv.tag ? { tag: argv.tag } : {},
10381
- ...argv.otp ? { otp: argv.otp } : {}
10382
- })).status === "fail") process.exitCode = 1;
10905
+ ...otp ? { otp } : {}
10906
+ });
10907
+ if (report.status === "fail") process.exitCode = 1;
10908
+ else if (report.status === "ok" && report.packageNames.length === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
10383
10909
  return;
10384
10910
  }
10385
10911
  if ((await runSteadyPublish({
@@ -10388,7 +10914,7 @@ try {
10388
10914
  provenance: argv.provenance,
10389
10915
  skipAlreadyPublished: argv.skipAlreadyPublished,
10390
10916
  ...argv.tag ? { tag: argv.tag } : {},
10391
- ...argv.otp ? { otp: argv.otp } : {}
10917
+ ...otp ? { otp } : {}
10392
10918
  })).status === "fail") process.exitCode = 1;
10393
10919
  }).command("$0", false, () => {}, async (argv) => {
10394
10920
  await launchGroupMenu("package", argv);