@theholocron/cli 5.0.0-alpha.177 → 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
@@ -4181,8 +4376,22 @@ function printNextSteps$1(print, inputs) {
4181
4376
  print(" 7. Commit + push when capability is functionally complete.");
4182
4377
  }
4183
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
4184
4392
  //#region src/commands/publish/default-exec.ts
4185
4393
  const defaultExec$1 = async (cmd, args, opts) => {
4394
+ const { HOLOCRON_OTP: _omittedOtp, ...env } = process.env;
4186
4395
  const result = spawnSync(cmd, args, {
4187
4396
  cwd: opts.cwd,
4188
4397
  encoding: "utf8",
@@ -4190,7 +4399,8 @@ const defaultExec$1 = async (cmd, args, opts) => {
4190
4399
  "inherit",
4191
4400
  "pipe",
4192
4401
  "pipe"
4193
- ]
4402
+ ],
4403
+ env
4194
4404
  });
4195
4405
  return {
4196
4406
  exitCode: result.status ?? -1,
@@ -4244,46 +4454,117 @@ function discoverManifests(cwd) {
4244
4454
  }));
4245
4455
  }
4246
4456
  //#endregion
4247
- //#region src/commands/publish/discover-public-packages.ts
4248
- /** The names of `discoverPublicPackageEntries`' packages, without requiring a version. */
4249
- function discoverPublicPackages(cwd) {
4250
- 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;
4251
4517
  }
4252
4518
  //#endregion
4253
4519
  //#region src/commands/publish/print-next-steps.ts
4254
- 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");
4255
4528
  print("");
4256
- print(" → next: configure Trusted Publisher for each package on npm:");
4529
+ print("→ packages to be published:");
4257
4530
  for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
4258
- print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
4259
4531
  if (env.get("NPM_TOKEN")) {
4260
4532
  print("");
4261
- 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):");
4262
4534
  print(" https://www.npmjs.com/settings/~/tokens");
4263
4535
  }
4264
4536
  }
4265
4537
  //#endregion
4266
- //#region src/commands/publish/resolve-repo-name.ts
4267
- async function resolveRepoName(cwd, exec) {
4268
- return (await readGitRemote(cwd, { exec: async (args, options) => {
4269
- const result = await exec("git", args, options);
4270
- if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4271
- return result.stdout;
4272
- } }))?.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);
4273
4549
  }
4274
4550
  //#endregion
4275
- //#region src/commands/publish/workspace-filter-args.ts
4551
+ //#region src/commands/publish/resolve-repo-identity.ts
4276
4552
  /**
4277
- * `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
4278
- * roots exist. Empty (single-package repo, no `-r`) when neither does.
4279
- * Shared by every `pnpm publish` invocation below so a repo with only
4280
- * `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.
4281
4557
  */
4282
- function workspaceFilterArgs(cwd) {
4283
- const filters = [];
4284
- if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
4285
- if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
4286
- 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
+ };
4287
4568
  }
4288
4569
  //#endregion
4289
4570
  //#region src/commands/publish/run-publish/run-publish.ts
@@ -4313,10 +4594,31 @@ function workspaceFilterArgs(cwd) {
4313
4594
  * outside the semantic-release/OIDC steady state) isn't built yet; the flag
4314
4595
  * exists so the surface doesn't need another rename when it is.
4315
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
+ *
4316
4606
  * If `NPM_TOKEN` is detected in env, the command prints a final
4317
4607
  * "revoke this token at <url>" reminder — same pattern as `rando vc
4318
4608
  * setup` for the ephemeral GH admin PAT.
4319
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
+ }
4320
4622
  async function runPublish(input = {}) {
4321
4623
  const print = input.print ?? ((line) => console.log(line));
4322
4624
  const logger = input.logger ?? getLogger();
@@ -4327,26 +4629,12 @@ async function runPublish(input = {}) {
4327
4629
  const env = makeEnv(input.env);
4328
4630
  const exec = input.exec ?? defaultExec$1;
4329
4631
  const login = input.login ?? defaultLogin;
4330
- const publishArgs = [
4331
- ...workspaceFilterArgs(cwd),
4332
- "publish",
4333
- "--access",
4334
- "public",
4335
- "--no-git-checks",
4336
- "--tag",
4337
- tag,
4338
- ...otp ? ["--otp", otp] : []
4339
- ];
4340
- print(`Holocron publish --initial${dryRun ? " (dry-run)" : ""}`);
4341
- print(` cwd: ${cwd}`);
4342
- print(` tag: ${tag}`);
4343
4632
  logger.info({
4344
4633
  tag,
4345
4634
  dryRun: dryRun || void 0
4346
4635
  }, "publish --initial: start");
4347
- if (otp) print(` otp: <${otp.length} chars>`);
4348
4636
  print("");
4349
- print(" → verifying npm auth (`npm whoami`)…");
4637
+ print(style.dim(" → verifying npm auth (`npm whoami`)…"));
4350
4638
  let whoami = await exec("npm", ["whoami"], { cwd });
4351
4639
  if (whoami.exitCode !== 0) {
4352
4640
  print(" not authenticated — running `npm login --auth-type=web`…");
@@ -4354,7 +4642,7 @@ async function runPublish(input = {}) {
4354
4642
  if ((await login(cwd)).exitCode !== 0) {
4355
4643
  const message = "npm login failed. Run `npm login --auth-type=web` (or `npm login`) manually, then re-run.";
4356
4644
  print(` ✗ ${message}`);
4357
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4645
+ const packageNames = (input.packages ?? discoverPublicPackageEntries(cwd)).map((e) => e.name);
4358
4646
  logger.warn({ reason: "npm login failed" }, "publish --initial: done");
4359
4647
  return {
4360
4648
  status: "fail",
@@ -4366,7 +4654,7 @@ async function runPublish(input = {}) {
4366
4654
  if (whoami.exitCode !== 0) {
4367
4655
  const message = "npm login completed but `npm whoami` still fails — check your npm account.";
4368
4656
  print(` ✗ ${message}`);
4369
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4657
+ const packageNames = (input.packages ?? discoverPublicPackageEntries(cwd)).map((e) => e.name);
4370
4658
  logger.warn({ reason: "npm whoami still fails after login" }, "publish --initial: done");
4371
4659
  return {
4372
4660
  status: "fail",
@@ -4375,10 +4663,35 @@ async function runPublish(input = {}) {
4375
4663
  };
4376
4664
  }
4377
4665
  }
4378
- print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
4379
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
4380
- 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
+ };
4381
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
+ }
4382
4695
  const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
4383
4696
  print(` ✗ ${message}`);
4384
4697
  logger.warn({ reason: message }, "publish --initial: done");
@@ -4388,11 +4701,22 @@ async function runPublish(input = {}) {
4388
4701
  packageNames
4389
4702
  };
4390
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
+ ];
4391
4714
  if (dryRun) {
4392
4715
  print("");
4393
- print(" … (dry-run) skipping actual publish");
4394
- print(` would run: pnpm ${publishArgs.join(" ")}`);
4395
- 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);
4396
4720
  logger.info({
4397
4721
  tag,
4398
4722
  status: "dry-run",
@@ -4405,7 +4729,7 @@ async function runPublish(input = {}) {
4405
4729
  };
4406
4730
  }
4407
4731
  print("");
4408
- print(" → publishing all public @theholocron/* packages…");
4732
+ print(` → publishing ${packageNames.length} new package${packageNames.length === 1 ? "" : "s"}…`);
4409
4733
  const publish = await exec("pnpm", publishArgs, { cwd });
4410
4734
  if (publish.exitCode !== 0) {
4411
4735
  const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
@@ -4415,6 +4739,12 @@ async function runPublish(input = {}) {
4415
4739
  print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
4416
4740
  print(` pnpm exec tsx packages/cli/src/cli.ts package publish --initial --otp <6-digit-code>`);
4417
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);
4418
4748
  logger.warn({
4419
4749
  tag,
4420
4750
  reason: message
@@ -4425,8 +4755,13 @@ async function runPublish(input = {}) {
4425
4755
  packageNames
4426
4756
  };
4427
4757
  }
4428
- print(" ✓ publish complete");
4429
- 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);
4430
4765
  logger.info({
4431
4766
  tag,
4432
4767
  status: "ok",
@@ -4438,19 +4773,18 @@ async function runPublish(input = {}) {
4438
4773
  };
4439
4774
  }
4440
4775
  //#endregion
4441
- //#region src/commands/publish/discover-public-package-entries.ts
4776
+ //#region src/commands/publish/workspace-filter-args.ts
4442
4777
  /**
4443
- * Every public package (`!private && name`) with its version and a
4444
- * `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
4445
- * mode needs both to check `npm view <pkg>@<version>` and publish one
4446
- * 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.
4447
4782
  */
4448
- function discoverPublicPackageEntries(cwd) {
4449
- return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
4450
- name: manifest.name,
4451
- version: manifest.version,
4452
- dir
4453
- }] : []);
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] : [];
4454
4788
  }
4455
4789
  //#endregion
4456
4790
  //#region src/commands/publish/run-steady-publish/run-steady-publish.ts
@@ -4524,7 +4858,7 @@ async function runSteadyPublish(input = {}) {
4524
4858
  ...otpFlags
4525
4859
  ];
4526
4860
  print(` … (dry-run) skipping actual publish`);
4527
- print(` would run: pnpm ${args.join(" ")}`);
4861
+ print(` would run: pnpm ${redactOtp(args, otp).join(" ")}`);
4528
4862
  }
4529
4863
  logger.info({
4530
4864
  tag,
@@ -4557,6 +4891,15 @@ async function runSteadyPublish(input = {}) {
4557
4891
  ...otpFlags
4558
4892
  ], { cwd });
4559
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
+ }
4560
4903
  const message = `publish failed for ${entry.name}@${entry.version} (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
4561
4904
  print(` ✗ ${message}`);
4562
4905
  logger.warn({
@@ -4619,6 +4962,12 @@ async function runSteadyPublish(input = {}) {
4619
4962
  };
4620
4963
  }
4621
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
4622
4971
  //#region src/commands/publish/is-eotp.ts
4623
4972
  function isEotp(parsed) {
4624
4973
  return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
@@ -8814,6 +9163,18 @@ async function runUpgradeNode(input) {
8814
9163
  //#endregion
8815
9164
  //#region src/interactive-menu/child-argv/child-argv.ts
8816
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
+ /**
8817
9178
  * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
8818
9179
  * menu-launched child should inherit from the parent invocation. Everything
8819
9180
  * else (command-specific options) was never captured by the top-level `$0`
@@ -8833,17 +9194,78 @@ function forwardedFlags(argv, dryRunOverride) {
8833
9194
  if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8834
9195
  return out;
8835
9196
  }
8836
- /** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
8837
- 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]));
8838
9227
  return [
8839
9228
  ...entry.spawnArgs ?? entry.name.split(" "),
8840
9229
  ...positionals,
9230
+ ...optionArgs,
8841
9231
  ...forwardedFlags(parentArgv, dryRunOverride)
8842
9232
  ];
8843
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
+ }
8844
9251
  //#endregion
8845
9252
  //#region src/interactive-menu/command-registry/command-registry.ts
8846
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
+ };
8847
9269
  /**
8848
9270
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
8849
9271
  * yargs registrations (this module does not introspect yargs at runtime).
@@ -8954,7 +9376,56 @@ const COMMAND_REGISTRY = [
8954
9376
  name: "package publish",
8955
9377
  description: "Publish @theholocron/* packages to npm",
8956
9378
  positionals: [],
8957
- 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
+ ]
8958
9429
  },
8959
9430
  {
8960
9431
  name: "repo sync",
@@ -9176,6 +9647,25 @@ function spawnChild(args, extraEnv) {
9176
9647
  });
9177
9648
  }
9178
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
+ /**
9179
9669
  * Whether `launchMenu` should auto-preview a picked command before running
9180
9670
  * it for real — only when the command's handler actually honours
9181
9671
  * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
@@ -9228,8 +9718,13 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
9228
9718
  else picked = entries.find((e) => e.name === value);
9229
9719
  }
9230
9720
  const positionals = await promptForPositionals(picked, {});
9721
+ const resolvedOptions = await promptForOptions(picked, {});
9722
+ const extraEnv = secretEnv(resolvedOptions);
9231
9723
  if (shouldPreviewDryRun(picked, parentArgv)) {
9232
- 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
+ });
9233
9728
  if (dryExit === 2) {
9234
9729
  process.exitCode = 0;
9235
9730
  return;
@@ -9245,10 +9740,12 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
9245
9740
  process.exitCode = 0;
9246
9741
  return;
9247
9742
  }
9248
- 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));
9249
9745
  return;
9250
9746
  }
9251
- 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));
9252
9749
  }
9253
9750
  //#endregion
9254
9751
  //#region src/interactive-menu/run-menu-entries/run-menu-entries.ts
@@ -10383,6 +10880,7 @@ try {
10383
10880
  default: false,
10384
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."
10385
10882
  }), async (argv) => {
10883
+ const otp = resolveOtp(argv.otp, env);
10386
10884
  if (argv.syncTrust) {
10387
10885
  const [oldFile, newFile] = argv.syncTrust;
10388
10886
  if (!oldFile || !newFile) {
@@ -10400,12 +10898,14 @@ try {
10400
10898
  return;
10401
10899
  }
10402
10900
  if (argv.initial) {
10403
- if ((await runPublish({
10901
+ const report = await runPublish({
10404
10902
  cwd: argv.cwd,
10405
10903
  dryRun: argv.dryRun,
10406
10904
  ...argv.tag ? { tag: argv.tag } : {},
10407
- ...argv.otp ? { otp: argv.otp } : {}
10408
- })).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;
10409
10909
  return;
10410
10910
  }
10411
10911
  if ((await runSteadyPublish({
@@ -10414,7 +10914,7 @@ try {
10414
10914
  provenance: argv.provenance,
10415
10915
  skipAlreadyPublished: argv.skipAlreadyPublished,
10416
10916
  ...argv.tag ? { tag: argv.tag } : {},
10417
- ...argv.otp ? { otp: argv.otp } : {}
10917
+ ...otp ? { otp } : {}
10418
10918
  })).status === "fail") process.exitCode = 1;
10419
10919
  }).command("$0", false, () => {}, async (argv) => {
10420
10920
  await launchGroupMenu("package", argv);