@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/README.md +52 -4
- package/dist/cli.mjs +606 -80
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
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-
|
|
4222
|
-
/**
|
|
4223
|
-
|
|
4224
|
-
|
|
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,
|
|
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("
|
|
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("
|
|
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/
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
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/
|
|
4551
|
+
//#region src/commands/publish/resolve-repo-identity.ts
|
|
4250
4552
|
/**
|
|
4251
|
-
*
|
|
4252
|
-
*
|
|
4253
|
-
*
|
|
4254
|
-
*
|
|
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
|
|
4257
|
-
const
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
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(
|
|
4368
|
-
print(`
|
|
4369
|
-
|
|
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(
|
|
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("
|
|
4403
|
-
|
|
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/
|
|
4776
|
+
//#region src/commands/publish/workspace-filter-args.ts
|
|
4416
4777
|
/**
|
|
4417
|
-
*
|
|
4418
|
-
*
|
|
4419
|
-
*
|
|
4420
|
-
*
|
|
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
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
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
|
-
/**
|
|
8811
|
-
|
|
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), {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10901
|
+
const report = await runPublish({
|
|
10378
10902
|
cwd: argv.cwd,
|
|
10379
10903
|
dryRun: argv.dryRun,
|
|
10380
10904
|
...argv.tag ? { tag: argv.tag } : {},
|
|
10381
|
-
...
|
|
10382
|
-
})
|
|
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
|
-
...
|
|
10917
|
+
...otp ? { otp } : {}
|
|
10392
10918
|
})).status === "fail") process.exitCode = 1;
|
|
10393
10919
|
}).command("$0", false, () => {}, async (argv) => {
|
|
10394
10920
|
await launchGroupMenu("package", argv);
|