package-registry-manager 0.21.0

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/src/setup.mjs ADDED
@@ -0,0 +1,820 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+
7
+ import { exec } from "command-stream";
8
+
9
+ import {
10
+ ANSI,
11
+ nodeOptionsWithShim,
12
+ runInteractive,
13
+ writeTtyShim,
14
+ } from "./auth-urls.mjs";
15
+ import {
16
+ approvalDeadline,
17
+ TWO_FACTOR_HINT,
18
+ oidcReleaseNote,
19
+ withFreshLinks,
20
+ } from "./approvals.mjs";
21
+ import { connectAutomation } from "./automation.mjs";
22
+ import {
23
+ detachedRunner,
24
+ npmPrefillScript,
25
+ openInUserBrowser,
26
+ } from "./browser.mjs";
27
+ import { detectDefaultBrowser, openWithCommand } from "./default-browser.mjs";
28
+ import { CLEANUP_CONDITIONS } from "./flows.mjs";
29
+ import { pagesSettingsUrl, pagesState } from "./pages.mjs";
30
+ import { packageDirectory } from "./plan.mjs";
31
+ import { twoFactorMode } from "./prerequisites.mjs";
32
+ import { protectLegacyProfile } from "./profile.mjs";
33
+ import { getJson, probePackage } from "./registry-state.mjs";
34
+ import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
35
+
36
+ export { defaultBrowserProfile } from "./profile.mjs";
37
+
38
+ const PREFILLED_FORMS = new Set([
39
+ "configure-trusted-publisher",
40
+ "create-pending-publisher",
41
+ ]);
42
+ const INTERACTIVE_CHECKS = new Set(["check-trust", "verify-trusted-publisher"]);
43
+ const PAGES_CHANGES = new Set(["enable-pages", "use-pages-workflow"]);
44
+ /** Checks whose failure is an answer, so their output is not echoed. */
45
+ const QUIET_CHECKS = new Set(["check-sign-in", "check-pages"]);
46
+ /** npm's answer when a workflow publishes without an attached trusted publisher. */
47
+ const PUBLISH_REJECTED = /\bE404\b|404 Not Found|invalid-publisher/i;
48
+ /** Where `--browser` opens URLs that need no automation. */
49
+ export const BROWSER_MODES = ["default", "automated"];
50
+
51
+ /**
52
+ * Runs a setup plan. Without `options.execute` it only reports a dry run.
53
+ * Steps whose `when` condition does not hold are skipped, so a re-run after a
54
+ * partial success resumes where the previous run stopped.
55
+ */
56
+ export async function executePlan(plan, options) {
57
+ if (!plan.package.publishable) {
58
+ throw new Error(
59
+ `${plan.package.name} is not publishable: ${(plan.package.problems ?? []).join("; ")}`,
60
+ );
61
+ }
62
+ if (plan.mode === "complete") {
63
+ const remaining =
64
+ plan.steps.length === 0
65
+ ? "nothing to do."
66
+ : "only the repository checks remain.";
67
+ console.log(
68
+ `${plan.package.name} already publishes through trusted publishing; ${remaining}`,
69
+ );
70
+ if (plan.steps.length === 0) {
71
+ return;
72
+ }
73
+ }
74
+ if (!options.execute) {
75
+ console.log(
76
+ "Dry run only. Re-run with --execute to run these steps and open the registry.",
77
+ );
78
+ return;
79
+ }
80
+ if (options.browserProfile) {
81
+ await protectLegacyProfile(
82
+ options.repository,
83
+ options.browserProfile,
84
+ options,
85
+ );
86
+ }
87
+ const session = new SetupSession(plan, options);
88
+ try {
89
+ await session.run();
90
+ } finally {
91
+ await session.cleanup();
92
+ }
93
+ if (plan.trusted_publisher?.workflow) {
94
+ console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
95
+ }
96
+ }
97
+
98
+ class SetupSession {
99
+ constructor(plan, options) {
100
+ this.plan = plan;
101
+ this.options = options;
102
+ this.conditions = new Set(["verify-release", "release-dispatch"]);
103
+ if (plan.package.exists_on_registry === false) {
104
+ this.conditions.add("package-missing");
105
+ }
106
+ if (plan.package.trusted_publishing !== true) {
107
+ this.conditions.add("trust-missing");
108
+ }
109
+ this.values = {};
110
+ this.deferred = [];
111
+ this.automation = null;
112
+ this.temporary = null;
113
+ this.shim = null;
114
+ }
115
+
116
+ async run() {
117
+ for (const step of this.plan.steps) {
118
+ if (CLEANUP_CONDITIONS.has(step.when)) {
119
+ this.deferred.push(step);
120
+ } else if (!step.when || this.conditions.has(step.when)) {
121
+ this.log(`==> ${step.title}`);
122
+ await this.runStep(step);
123
+ } else if (this.options.verbose) {
124
+ console.error(`skip ${step.id}: condition ${step.when} does not hold`);
125
+ }
126
+ }
127
+ }
128
+
129
+ async cleanup() {
130
+ for (const step of this.deferred) {
131
+ if (step.id === "sign-out" && this.keepsSession(step)) {
132
+ console.log(
133
+ "Keeping the npm session (--keep-session): its token stays in npm's user configuration until you run npm logout, and the next run reuses it while npm whoami succeeds.",
134
+ );
135
+ } else if (this.conditions.has(step.when)) {
136
+ this.log(`==> ${step.title}`);
137
+ const result = await this.runProcess(step, true);
138
+ if (result.code !== 0) {
139
+ console.error(`warning: ${step.id} exited with ${result.code}`);
140
+ }
141
+ }
142
+ }
143
+ if (this.automation) {
144
+ await this.automation.close();
145
+ }
146
+ for (const directory of [this.temporary, this.shim?.directory]) {
147
+ if (directory) {
148
+ await rm(directory, { recursive: true, force: true });
149
+ }
150
+ }
151
+ }
152
+
153
+ keepsSession(step) {
154
+ return (
155
+ Boolean(this.options.keepSession) &&
156
+ this.conditions.has(step.when) &&
157
+ step.command?.program === "npm"
158
+ );
159
+ }
160
+
161
+ async runStep(step) {
162
+ switch (step.kind) {
163
+ case "check":
164
+ if (step.id === "check-registry") {
165
+ return this.checkRegistry();
166
+ }
167
+ if (step.id === "verify-token-revoked") {
168
+ return verifyTokenRevoked((message) => this.prompt(message), {
169
+ ...this.options,
170
+ env: this.options.env ?? process.env,
171
+ });
172
+ }
173
+ return step.id === "verify-bins" ? this.verifyBins() : this.check(step);
174
+ case "command":
175
+ return step.id === "delete-token-secret"
176
+ ? this.deleteTokenSecrets(step)
177
+ : this.command(step);
178
+ case "wait":
179
+ return this.wait(step);
180
+ case "browser":
181
+ return this.browser(step);
182
+ default:
183
+ console.log(step.description);
184
+ if (step.url) {
185
+ console.log(` ${step.url}`);
186
+ }
187
+ await this.prompt("Press Enter when this is done...");
188
+ }
189
+ }
190
+
191
+ async checkRegistry() {
192
+ const state = await probePackage(this.plan.package, this.options);
193
+ if (state.exists === undefined) {
194
+ throw new Error(
195
+ `could not determine whether ${this.plan.package.name} exists on ${this.plan.registry}`,
196
+ );
197
+ }
198
+ toggle(this.conditions, "package-missing", !state.exists);
199
+ if (state.trusted === true) {
200
+ this.conditions.delete("trust-missing");
201
+ }
202
+ this.values.previous_version = state.version;
203
+ if (
204
+ !state.exists &&
205
+ !this.plan.steps.some((s) => s.when === "package-missing")
206
+ ) {
207
+ throw new Error(
208
+ `${this.plan.package.name} is missing from the registry; re-run plan to get the bootstrap steps`,
209
+ );
210
+ }
211
+ console.log(
212
+ state.exists
213
+ ? " The package exists; attaching trusted publishing."
214
+ : " The package is not published yet; bootstrapping it.",
215
+ );
216
+ }
217
+
218
+ async check(step) {
219
+ const result = INTERACTIVE_CHECKS.has(step.id)
220
+ ? await this.runProcess(step, false)
221
+ : await this.capture(step);
222
+ const output = String(result.stdout ?? "");
223
+ switch (step.id) {
224
+ case "check-sign-in":
225
+ toggle(this.conditions, "signed-out", result.code !== 0);
226
+ return;
227
+ case "check-trust":
228
+ case "verify-trusted-publisher": {
229
+ const trusted =
230
+ result.code === 0 && /"(?:id|type|file)"\s*:/.test(output);
231
+ toggle(this.conditions, "trust-missing", !trusted);
232
+ if (step.id === "verify-trusted-publisher" && !trusted) {
233
+ throw new Error(
234
+ "npm does not list a trusted publisher for the package",
235
+ );
236
+ }
237
+ return;
238
+ }
239
+ case "check-2fa":
240
+ case "verify-2fa":
241
+ return this.checkTwoFactor(step, result);
242
+ case "inspect-release-run":
243
+ return this.inspectReleaseRun(result);
244
+ case "read-release-failure":
245
+ if (result.code !== 0) {
246
+ console.error("warning: could not read the failed release log");
247
+ } else if (PUBLISH_REJECTED.test(output)) {
248
+ console.log(
249
+ " It failed at publish: npm rejected the workflow (E404/invalid-publisher) because no trusted publisher is attached yet. After attaching trust, its failed jobs can be re-run.",
250
+ );
251
+ this.conditions.add("release-failed-publish");
252
+ } else {
253
+ console.log(
254
+ " It failed for another reason, so trusted publishing alone will not fix it; it is not re-run.",
255
+ );
256
+ }
257
+ return;
258
+ case "audit-token-secrets":
259
+ if (result.code !== 0) {
260
+ console.error("warning: could not list repository secrets with gh");
261
+ return;
262
+ }
263
+ this.tokenSecrets = reportTokenSecrets(output, this.plan.package);
264
+ toggle(
265
+ this.conditions,
266
+ "token-secret-present",
267
+ this.tokenSecrets.length > 0,
268
+ );
269
+ return;
270
+ case "check-pages":
271
+ return this.checkPages(result);
272
+ case "find-release-run": {
273
+ const [run] = result.code === 0 ? JSON.parse(output || "[]") : [];
274
+ if (!run) {
275
+ throw new Error("no release workflow run was found");
276
+ }
277
+ this.values.run_id = String(run.databaseId);
278
+ return;
279
+ }
280
+ default:
281
+ if (result.code !== 0) {
282
+ throw new Error(
283
+ `${step.command.program} exited with status ${result.code}`,
284
+ );
285
+ }
286
+ }
287
+ }
288
+
289
+ checkTwoFactor(step, result) {
290
+ let mode;
291
+ try {
292
+ mode = result.code === 0 ? twoFactorMode(result.stdout) : undefined;
293
+ } catch {
294
+ mode = undefined;
295
+ }
296
+ if (mode === undefined) {
297
+ if (step.id === "verify-2fa") {
298
+ throw new Error("could not read the npm profile to verify 2FA");
299
+ }
300
+ console.error(
301
+ "warning: could not read the npm profile; npm trust requires two-factor authentication",
302
+ );
303
+ return;
304
+ }
305
+ if (mode) {
306
+ console.log(` Two-factor authentication is on (${mode}).`);
307
+ this.conditions.delete("tfa-disabled");
308
+ return;
309
+ }
310
+ if (step.id === "verify-2fa") {
311
+ throw new Error(
312
+ "two-factor authentication is still off; npm trust requires it, so nothing was published",
313
+ );
314
+ }
315
+ console.log(
316
+ " Two-factor authentication is off; npm trust requires it, so turn it on before anything is published.",
317
+ );
318
+ this.conditions.add("tfa-disabled");
319
+ }
320
+
321
+ checkPages(result) {
322
+ const state = pagesState(result);
323
+ toggle(this.conditions, "pages-missing", state === "missing");
324
+ toggle(this.conditions, "pages-legacy", state === "legacy");
325
+ if (state === "workflow") {
326
+ console.log(
327
+ " GitHub Pages is enabled with GitHub Actions as its source.",
328
+ );
329
+ } else if (state === "legacy") {
330
+ console.log(
331
+ " GitHub Pages builds from a branch, so the workflow's deployment is not served.",
332
+ );
333
+ } else if (state === "missing") {
334
+ console.log(
335
+ " GitHub Pages is not enabled, so the workflow's deployment fails with Not Found.",
336
+ );
337
+ } else {
338
+ console.error(
339
+ `warning: could not read the GitHub Pages site; check its source at ${this.pagesSettings()}`,
340
+ );
341
+ }
342
+ }
343
+
344
+ pagesSettings() {
345
+ const { github_owner, github_repository } = this.plan.repository;
346
+ return pagesSettingsUrl(`${github_owner}/${github_repository}`);
347
+ }
348
+
349
+ inspectReleaseRun(result) {
350
+ if (result.code !== 0) {
351
+ console.error("warning: could not list the release workflow runs");
352
+ return;
353
+ }
354
+ const [run] = JSON.parse(String(result.stdout || "[]"));
355
+ if (!run) {
356
+ console.log(" The release workflow has not run yet.");
357
+ return;
358
+ }
359
+ const outcome = run.conclusion || run.status;
360
+ console.log(` The latest release run ${outcome}: ${run.url}`);
361
+ if (run.conclusion === "failure") {
362
+ this.values.failed_run_id = String(run.databaseId);
363
+ this.conditions.add("release-failed");
364
+ }
365
+ }
366
+
367
+ async verifyBins() {
368
+ const pack = this.plan.steps.find((item) => item.id === "pack");
369
+ const source = await readManifest(
370
+ path.join(this.cwd(pack), "package.json"),
371
+ );
372
+ const prefix = path.join(this.values.pack_destination, "install");
373
+ const root = path.join(
374
+ prefix,
375
+ "node_modules",
376
+ ...this.plan.package.name.split("/"),
377
+ );
378
+ // npm extracts the tarball's package/package.json unchanged.
379
+ const packed = await readManifest(path.join(root, "package.json"));
380
+ const removed = Object.keys(binEntries(source)).filter(
381
+ (name) => !(name in binEntries(packed)),
382
+ );
383
+ if (removed.length > 0) {
384
+ throw new Error(
385
+ `the packed package.json has no bin ${removed.join(", ")}; npm removed it while packing, so correct the bin entries in package.json before the first publish`,
386
+ );
387
+ }
388
+ const bins = Object.entries(binEntries(packed));
389
+ if (bins.length === 0) {
390
+ console.log(" The package has no bin entries.");
391
+ }
392
+ for (const [name, file] of bins) {
393
+ // Windows links bins as .cmd shims, which cannot run without a shell.
394
+ const command =
395
+ process.platform === "win32"
396
+ ? { program: "node", args: [path.join(root, file), "--version"] }
397
+ : {
398
+ program: path.join(prefix, "node_modules", ".bin", name),
399
+ args: ["--version"],
400
+ };
401
+ if (this.options.verbose) {
402
+ console.error(`+ (cd ${prefix} && ${render(command)})`);
403
+ }
404
+ const result = await exec(command.program, command.args, {
405
+ cwd: prefix,
406
+ capture: true,
407
+ mirror: this.options.verbose,
408
+ stdin: "ignore",
409
+ });
410
+ if (result.code !== 0) {
411
+ if (!this.options.verbose) {
412
+ stdout.write(String(result.stdout ?? ""));
413
+ process.stderr.write(String(result.stderr ?? ""));
414
+ }
415
+ throw new Error(
416
+ `bin ${name} (${file}) exited with status ${result.code} when run with --version from the installed tarball`,
417
+ );
418
+ }
419
+ // A bin whose entry point never runs exits 0 without output.
420
+ const output =
421
+ String(result.stdout ?? "").trim() ||
422
+ String(result.stderr ?? "").trim();
423
+ if (!output) {
424
+ throw new Error(
425
+ `bin ${name} (${file}) printed nothing when run with --version from the installed tarball; make sure it runs when started through the node_modules/.bin symlink`,
426
+ );
427
+ }
428
+ console.log(` ${name} --version: ${output.split("\n")[0]}`);
429
+ }
430
+ }
431
+
432
+ /** Deletes each leftover token secret, with one confirmation each. */
433
+ async deleteTokenSecrets(step) {
434
+ for (const name of this.tokenSecrets ?? []) {
435
+ this.values.token_secret = name;
436
+ await this.command(step);
437
+ }
438
+ }
439
+
440
+ async command(step) {
441
+ if (step.confirm && !this.options.yes) {
442
+ const rendered = render(this.expand(step.command));
443
+ const answer = await this.prompt(`Run \`${rendered}\`? [y/N] `);
444
+ if (!/^(?:y|yes)$/i.test(answer)) {
445
+ if (step.id === "first-publish") {
446
+ throw new Error("the first publish was declined");
447
+ }
448
+ console.log(` skipped ${step.id}`);
449
+ return;
450
+ }
451
+ }
452
+ if (step.id === "prepare-worktree") {
453
+ this.temporary = await mkdtemp(path.join(os.tmpdir(), "prm-release-"));
454
+ this.values.worktree = path.join(this.temporary, "worktree");
455
+ this.values.pack_destination = this.temporary;
456
+ }
457
+ // Pack output is captured so npm's warnings can be reviewed.
458
+ const result =
459
+ step.id === "pack"
460
+ ? await this.capture(step)
461
+ : await this.runProcess(step, true);
462
+ if (result.code !== 0) {
463
+ if (step.id === "attach-trusted-publisher") {
464
+ console.error(
465
+ "warning: npm trust failed; falling back to the browser form",
466
+ );
467
+ this.conditions.add("trust-cli-failed");
468
+ return;
469
+ }
470
+ if (PAGES_CHANGES.has(step.id)) {
471
+ console.error(
472
+ `warning: could not change GitHub Pages (this needs repository administrator rights); set its source to GitHub Actions at ${this.pagesSettings()}`,
473
+ );
474
+ return;
475
+ }
476
+ if (step.id === "trigger-release") {
477
+ console.error(
478
+ "warning: could not dispatch the workflow; watching its latest run",
479
+ );
480
+ return;
481
+ }
482
+ throw new Error(
483
+ `${step.command.program} exited with status ${result.code}`,
484
+ );
485
+ }
486
+ if (step.id === "sign-in") {
487
+ this.conditions.add("tool-signed-in");
488
+ } else if (step.id === "prepare-worktree") {
489
+ this.conditions.add("worktree-created");
490
+ } else if (step.id === "pack") {
491
+ reportPackWarnings(result.stderr);
492
+ this.recordPack(result.stdout);
493
+ } else if (step.id === "rerun-release") {
494
+ console.log(" Re-running the failed release jobs.");
495
+ this.values.run_id = this.values.failed_run_id;
496
+ this.conditions.delete("release-dispatch");
497
+ }
498
+ }
499
+
500
+ recordPack(output) {
501
+ const packed = packedEntry(output);
502
+ for (const file of packed.files ?? []) {
503
+ console.log(` ${String(file.size).padStart(8)} ${file.path}`);
504
+ }
505
+ console.log(
506
+ ` ${packed.filename}: ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked, ${packed.entryCount} files`,
507
+ );
508
+ this.values.tarball = path.join(
509
+ this.values.pack_destination,
510
+ packed.filename,
511
+ );
512
+ this.values.version = packed.version;
513
+ }
514
+
515
+ async wait(step) {
516
+ const url = this.expandText(step.url);
517
+ const deadline = Date.now() + (this.options.waitTimeoutMs ?? 20 * 60_000);
518
+ const interval = this.options.pollIntervalMs ?? 5_000;
519
+ for (;;) {
520
+ const document = await getJson(url, this.options);
521
+ if (
522
+ document &&
523
+ (step.id !== "confirm-provenance" ||
524
+ isTrustedRelease(document, this.values))
525
+ ) {
526
+ console.log(` ${url} is ready`);
527
+ return;
528
+ }
529
+ if (Date.now() > deadline) {
530
+ throw new Error(`timed out waiting for ${url}`);
531
+ }
532
+ await new Promise((resolve) => setTimeout(resolve, interval));
533
+ }
534
+ }
535
+
536
+ async browser(step) {
537
+ if (step.confirm && !this.options.yes) {
538
+ console.log(` ${step.description}`);
539
+ const answer = await this.prompt(`${step.title}? [y/N] `);
540
+ if (!/^(?:y|yes)$/i.test(answer)) {
541
+ throw new Error(
542
+ "the one-time first-publish token was declined; nothing was published",
543
+ );
544
+ }
545
+ }
546
+ // Only a form the tool fills needs the automated profile.
547
+ const fill = PREFILLED_FORMS.has(step.id) && this.plan.trusted_publisher;
548
+ await this.open(step.url, Boolean(fill));
549
+ if (fill && this.automation) {
550
+ await this.prefill();
551
+ }
552
+ await this.prompt("Finish this step in the browser, then press Enter...");
553
+ if (step.id === "create-pending-publisher") {
554
+ this.conditions.delete("trust-missing");
555
+ }
556
+ }
557
+
558
+ async prefill() {
559
+ await this.prompt("Press Enter when the form is visible...");
560
+ const page = this.automation;
561
+ const script = npmPrefillScript(this.plan.trusted_publisher, false);
562
+ let result = await page.evaluate(script);
563
+ if (result.filled?.length === 0) {
564
+ await new Promise((resolve) => setTimeout(resolve, 500));
565
+ result = await page.evaluate(script);
566
+ }
567
+ console.log(`Prefill result: ${JSON.stringify(result)}`);
568
+ const submit =
569
+ this.options.yes ||
570
+ /^(?:y|yes)$/i.test(
571
+ await this.prompt(
572
+ "Submit this trusted-publisher configuration? [y/N] ",
573
+ ),
574
+ );
575
+ if (submit) {
576
+ const submission = await page.evaluate(
577
+ npmPrefillScript(this.plan.trusted_publisher, true),
578
+ );
579
+ if (!submission.submitted) {
580
+ throw new Error(
581
+ "the form was not submitted; review the visible browser and submit manually",
582
+ );
583
+ }
584
+ }
585
+ }
586
+
587
+ async open(url, automate = false) {
588
+ if (this.options.noBrowser) {
589
+ console.log(`Open ${url}`);
590
+ return;
591
+ }
592
+ if (!automate && this.options.browser !== "automated") {
593
+ const app = this.options.openWith;
594
+ console.log(`Opening ${url} in ${app ?? this.defaultBrowserLabel()}`);
595
+ try {
596
+ if (app) {
597
+ const { program, args } = openWithCommand(url, app);
598
+ await detachedRunner()(program, args);
599
+ } else {
600
+ await openInUserBrowser(url);
601
+ }
602
+ } catch (error) {
603
+ console.error(
604
+ `warning: could not open ${app ?? "your default browser"} (${error.message}); open the URL yourself`,
605
+ );
606
+ }
607
+ return;
608
+ }
609
+ this.automation ??= await connectAutomation({
610
+ browser: this.options.browserOptions,
611
+ profile: this.options.browserProfile,
612
+ verbose: this.options.verbose,
613
+ });
614
+ await this.automation.goto(url);
615
+ }
616
+
617
+ /**
618
+ * Names the default browser once per run, so maintainers know where to look.
619
+ * Runs before npm starts: links are opened from a synchronous output callback.
620
+ */
621
+ async detectDefaultBrowser() {
622
+ const { noBrowser, browser, openWith, verbose } = this.options;
623
+ if (noBrowser || openWith || browser === "automated") {
624
+ return;
625
+ }
626
+ this.browserName ??= (await detectDefaultBrowser({ verbose })) ?? "";
627
+ }
628
+
629
+ defaultBrowserLabel() {
630
+ return this.browserName
631
+ ? `${this.browserName}, your default browser`
632
+ : "your default browser";
633
+ }
634
+
635
+ cwd(step) {
636
+ const relative = step.cwd ?? packageDirectory(this.plan.package.manifest);
637
+ const expanded = this.expandText(relative);
638
+ return path.isAbsolute(expanded)
639
+ ? expanded
640
+ : path.join(this.options.repository, expanded);
641
+ }
642
+
643
+ prepare(step) {
644
+ const command = this.expand(step.command);
645
+ const cwd = this.cwd(step);
646
+ if (this.options.verbose) {
647
+ console.error(`+ (cd ${cwd} && ${render(command)})`);
648
+ }
649
+ return { command, cwd };
650
+ }
651
+
652
+ async capture(step) {
653
+ const { command, cwd } = this.prepare(step);
654
+ const result = await exec(command.program, command.args, {
655
+ cwd,
656
+ capture: true,
657
+ mirror: this.options.verbose,
658
+ stdin: "ignore",
659
+ });
660
+ if (
661
+ !this.options.verbose &&
662
+ result.code !== 0 &&
663
+ !QUIET_CHECKS.has(step.id)
664
+ ) {
665
+ stdout.write(String(result.stdout ?? ""));
666
+ process.stderr.write(String(result.stderr ?? ""));
667
+ }
668
+ return result;
669
+ }
670
+
671
+ async runProcess(step, mirror) {
672
+ const { command, cwd } = this.prepare(step);
673
+ let env = process.env;
674
+ const npm = ["npm", "npx"].includes(command.program);
675
+ if (npm) {
676
+ await this.detectDefaultBrowser();
677
+ this.shim ??= await writeTtyShim();
678
+ env = {
679
+ ...process.env,
680
+ NODE_OPTIONS: nodeOptionsWithShim(
681
+ process.env.NODE_OPTIONS,
682
+ this.shim.shim,
683
+ ),
684
+ };
685
+ }
686
+ const run = () =>
687
+ runInteractive(command, {
688
+ cwd,
689
+ env,
690
+ mirror,
691
+ stopOnLegacyLogin: npm,
692
+ watchStderr: npm,
693
+ onUrl: (url, kind) => this.openLink(url, kind),
694
+ });
695
+ // npm's links expire after about 5 minutes; ask npm for a fresh one.
696
+ return npm ? withFreshLinks(run) : run();
697
+ }
698
+
699
+ openLink(url, kind) {
700
+ console.log(approvalDeadline(kind));
701
+ if (kind === "approve") {
702
+ console.log(TWO_FACTOR_HINT);
703
+ }
704
+ this.open(url).catch((error) =>
705
+ console.error(`warning: could not open ${url}: ${error.message}`),
706
+ );
707
+ }
708
+
709
+ expand(command) {
710
+ return {
711
+ program: command.program,
712
+ args: command.args.map((arg) => this.expandText(arg)),
713
+ };
714
+ }
715
+
716
+ expandText(text) {
717
+ return text.replaceAll(
718
+ /\{(\w+)\}/g,
719
+ (match, key) => this.values[key] ?? match,
720
+ );
721
+ }
722
+
723
+ log(message) {
724
+ console.log(message);
725
+ }
726
+
727
+ /** Asks on the terminal, or through `options.prompt` when given. */
728
+ prompt(message) {
729
+ return (this.options.prompt ?? prompt)(message);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * npm's warnings from `npm pack`, such as fields it auto-corrected or removed
735
+ * from the packed package.json, without npm's advice to run its fixer, which
736
+ * rewrites package.json in place.
737
+ */
738
+ export function packWarnings(stderr) {
739
+ return String(stderr ?? "")
740
+ .split(/\r?\n/)
741
+ .map((line) => line.replaceAll(ANSI, "").trim())
742
+ .filter((line) => /^npm warn/i.test(line))
743
+ .map((line) =>
744
+ line.replace(/\s*Please run "npm pkg fix"[^.]*\.?/i, "").trim(),
745
+ )
746
+ .filter((line) => !/npm pkg fix/i.test(line));
747
+ }
748
+
749
+ /**
750
+ * The first package in `npm pack --json` output: an array before npm 12, an
751
+ * object keyed by package name since.
752
+ */
753
+ export function packedEntry(output) {
754
+ const parsed = JSON.parse(output);
755
+ const [packed] = Array.isArray(parsed) ? parsed : Object.values(parsed ?? {});
756
+ if (!packed) {
757
+ throw new Error("npm pack printed no package");
758
+ }
759
+ return packed;
760
+ }
761
+
762
+ function reportPackWarnings(stderr) {
763
+ const warnings = packWarnings(stderr);
764
+ if (warnings.length === 0) {
765
+ return;
766
+ }
767
+ console.log(" npm warned while packing:");
768
+ for (const line of warnings) {
769
+ console.log(` ${line}`);
770
+ }
771
+ if (warnings.some((line) => /corrected|invalid and removed/i.test(line))) {
772
+ console.log(
773
+ " npm changed the packed package.json; correct these fields in package.json itself.",
774
+ );
775
+ }
776
+ }
777
+
778
+ function binEntries(manifest) {
779
+ if (typeof manifest.bin === "string") {
780
+ return { [String(manifest.name).replace(/^@[^/]+\//, "")]: manifest.bin };
781
+ }
782
+ return manifest.bin && typeof manifest.bin === "object" ? manifest.bin : {};
783
+ }
784
+
785
+ async function readManifest(file) {
786
+ return JSON.parse(await readFile(file, "utf8"));
787
+ }
788
+
789
+ function isTrustedRelease(document, values) {
790
+ return (
791
+ Boolean(document._npmUser?.trustedPublisher) &&
792
+ Boolean(document.dist?.attestations) &&
793
+ document.version !== values.previous_version
794
+ );
795
+ }
796
+
797
+ function toggle(set, value, enabled) {
798
+ if (enabled) {
799
+ set.add(value);
800
+ } else {
801
+ set.delete(value);
802
+ }
803
+ }
804
+
805
+ function render(command) {
806
+ return [command.program, ...command.args].join(" ");
807
+ }
808
+
809
+ /** Asks a question on the terminal; a closed stdin answers with "". */
810
+ async function prompt(message) {
811
+ const terminal = createInterface({ input: stdin, output: stdout });
812
+ try {
813
+ return await new Promise((resolve) => {
814
+ terminal.once("close", () => resolve(""));
815
+ terminal.question(message).then(resolve, () => resolve(""));
816
+ });
817
+ } finally {
818
+ terminal.close();
819
+ }
820
+ }