package-registry-manager 0.21.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "provenance": true
29
29
  },
30
30
  "scripts": {
31
- "syntax": "node --check src/cli.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
31
+ "syntax": "node --check src/cli.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
32
32
  "lint": "eslint src test scripts --max-warnings 0",
33
33
  "lint:fix": "eslint src test scripts --fix --max-warnings 0",
34
34
  "format": "prettier --write .",
@@ -9,6 +9,7 @@ import {
9
9
 
10
10
  import { snapshotBrowser } from "./browser-options.mjs";
11
11
  import { defaultBrowserProfile, ensureProfileIgnored } from "./profile.mjs";
12
+ import { IMPORT_CHOICES, migrationFor } from "./sign-in-import.mjs";
12
13
 
13
14
  /** How long `--browser-attach extension` waits for the extension to connect. */
14
15
  export const EXTENSION_TIMEOUT_MS = 300_000;
@@ -21,9 +22,11 @@ export function relayExtensionDirectory() {
21
22
  /**
22
23
  * The `launchRealBrowser` options for the automated browser: the dedicated
23
24
  * profile by default, optionally seeded from a real profile, or a temporary
24
- * snapshot of the user's own profile with `--browser-attach snapshot`.
25
+ * snapshot of the user's own profile with `--browser-attach snapshot`. With
26
+ * the `domains` import scope only the cookies of `domains`, the registry's
27
+ * sign-in domains, are imported.
25
28
  */
26
- export function launchOptions(browser, profile, verbose = false) {
29
+ export function launchOptions(browser, profile, verbose = false, domains = []) {
27
30
  const options = {
28
31
  engine: "playwright",
29
32
  channel: browser.channel,
@@ -48,11 +51,13 @@ export function launchOptions(browser, profile, verbose = false) {
48
51
  return options;
49
52
  }
50
53
  options.userDataDir = profile;
51
- if (browser.import) {
52
- options.migrateFrom = {
53
- browser: browser.import.browser,
54
- ...(browser.import.profile ? { profile: browser.import.profile } : {}),
55
- };
54
+ // `default` and `auto` are resolved to an installed browser before launch.
55
+ if (browser.import && !IMPORT_CHOICES.includes(browser.import.browser)) {
56
+ options.migrateFrom = migrationFor(
57
+ browser.import,
58
+ browser.importScope ?? "full",
59
+ domains,
60
+ );
56
61
  }
57
62
  return options;
58
63
  }
@@ -68,9 +73,14 @@ export function extensionInstructions(directory) {
68
73
 
69
74
  /**
70
75
  * Connects the browser that fills forms and returns a page with `goto`,
71
- * `evaluate`, and `close`.
76
+ * `evaluate`, `close`, and, for a launched profile, `clearCookies(domains)`.
72
77
  */
73
- export async function connectAutomation({ browser, profile, verbose = false }) {
78
+ export async function connectAutomation({
79
+ browser,
80
+ profile,
81
+ verbose = false,
82
+ domains = [],
83
+ }) {
74
84
  if (browser.attach?.mode === "extension") {
75
85
  return connectExtension();
76
86
  }
@@ -78,12 +88,20 @@ export async function connectAutomation({ browser, profile, verbose = false }) {
78
88
  await ensureProfileIgnored(profile, { verbose });
79
89
  }
80
90
  const connection = await launchRealBrowser(
81
- launchOptions(browser, profile, verbose),
91
+ launchOptions(browser, profile, verbose, domains),
82
92
  );
83
93
  return {
84
94
  goto: (url) => connection.page.goto(url),
85
95
  evaluate: (script) => connection.page.evaluate(script),
86
96
  close: () => connection.browser.close(),
97
+ async clearCookies(names) {
98
+ for (const name of names) {
99
+ const escaped = name.replaceAll(".", "\\.");
100
+ await connection.page
101
+ .context()
102
+ .clearCookies({ domain: new RegExp(`(?:^|\\.)${escaped}$`) });
103
+ }
104
+ },
87
105
  };
88
106
  }
89
107
 
@@ -5,8 +5,20 @@ import {
5
5
  LAUNCH_RESTRICTIONS,
6
6
  } from "browser-commander";
7
7
 
8
- /** Browsers whose profile `--browser-import` can migrate. */
9
- export const IMPORT_BROWSERS = ["chrome", "edge", "brave", "firefox"];
8
+ import {
9
+ IMPORT_CHOICES,
10
+ IMPORT_SCOPES,
11
+ importSources,
12
+ } from "./sign-in-import.mjs";
13
+
14
+ /**
15
+ * What `--browser-import` accepts: every browser Browser Commander reads,
16
+ * plus `default` (the system default browser) and `auto` (accept the
17
+ * sign-in import offer, the default browser first).
18
+ */
19
+ export function importBrowsers() {
20
+ return [...importSources(), ...IMPORT_CHOICES];
21
+ }
10
22
  /** How `--browser-attach` reaches the user's own browser. */
11
23
  export const ATTACH_MODES = ["snapshot", "extension"];
12
24
 
@@ -31,6 +43,7 @@ export function parseBrowserOptions({
31
43
  channel = "chrome",
32
44
  executable,
33
45
  importFrom,
46
+ importScope,
34
47
  attach,
35
48
  preferences = [],
36
49
  restrictions = [],
@@ -40,6 +53,7 @@ export function parseBrowserOptions({
40
53
  channel,
41
54
  executable: executable ? path.resolve(executable) : null,
42
55
  import: importFrom ? parseImport(importFrom) : null,
56
+ importScope: parseImportScope(importScope, importFrom),
43
57
  attach: attach ? parseAttach(attach) : null,
44
58
  preferences: parsePreferences(preferences),
45
59
  restrictions: validateRestrictions(restrictions),
@@ -67,19 +81,41 @@ export function parseBrowserOptions({
67
81
  return options;
68
82
  }
69
83
 
70
- /** Parses `<chrome|edge|brave|firefox>[:profile]`. */
84
+ /** Parses `<browser>[:profile]`, `default`, or `auto`. */
71
85
  export function parseImport(spec) {
72
86
  const separator = spec.indexOf(":");
73
87
  const browser = separator === -1 ? spec : spec.slice(0, separator);
74
88
  const profile = separator === -1 ? null : spec.slice(separator + 1);
75
- if (!IMPORT_BROWSERS.includes(browser) || profile === "") {
89
+ const choice = IMPORT_CHOICES.includes(browser);
90
+ if (
91
+ !importBrowsers().includes(browser) ||
92
+ profile === "" ||
93
+ (choice && profile !== null)
94
+ ) {
76
95
  throw new Error(
77
- "--browser-import must be <chrome|edge|brave|firefox>[:profile]",
96
+ `--browser-import must be <${importSources().join("|")}>[:profile], default, or auto`,
78
97
  );
79
98
  }
80
99
  return { browser, profile };
81
100
  }
82
101
 
102
+ /**
103
+ * Parses `--browser-import-scope`: `full` migrates the whole profile and
104
+ * `domains` only the registry's sign-in cookies. Without it a named browser
105
+ * is migrated fully, as before, and `default`, `auto`, and the sign-in offer
106
+ * import only the sign-in domains.
107
+ */
108
+ export function parseImportScope(scope, importFrom) {
109
+ if (scope === undefined || scope === null) {
110
+ const named = importFrom && !IMPORT_CHOICES.includes(importFrom);
111
+ return named ? "full" : "domains";
112
+ }
113
+ if (!IMPORT_SCOPES.includes(scope)) {
114
+ throw new Error("--browser-import-scope must be full or domains");
115
+ }
116
+ return scope;
117
+ }
118
+
83
119
  /** Parses `snapshot`, `snapshot:<profile>`, or `extension`. */
84
120
  export function parseAttach(spec) {
85
121
  if (spec === "extension") {
package/src/cli.mjs CHANGED
@@ -50,9 +50,13 @@ Setup options:
50
50
  --open-with <app> Open sign-in and approval pages in this
51
51
  application instead of the default browser,
52
52
  such as "Google Chrome" (macOS) or firefox
53
- --keep-session Stay signed in to npm after setup; the
54
- session token stays in npm's user
55
- configuration until npm logout
53
+ --manual crates.io: create, use, and revoke the
54
+ first-publish token by hand instead of
55
+ through the crates.io API
56
+ --keep-session Stay signed in after setup: npm keeps its
57
+ session token in npm's user configuration
58
+ until npm logout; crates.io keeps only the
59
+ automated profile's browser session
56
60
  --browser <default|automated> Open sign-in and approval pages in your
57
61
  default browser, or in the automated
58
62
  profile too (default: default)
@@ -63,10 +67,16 @@ Setup options:
63
67
  instead of the channel's
64
68
  --browser-profile <path> Dedicated automation profile, used to fill
65
69
  forms (default: per-user state directory)
66
- --browser-import <browser>[:<profile>]
67
- Copy cookies, history, and other data from
68
- your chrome, edge, brave, or firefox profile
69
- into the automated profile first
70
+ --browser-import <browser>[:<profile>]|default|auto
71
+ Copy data from your chrome, edge, brave,
72
+ chromium, or firefox profile into the
73
+ automated profile first; default takes the
74
+ system default browser, auto the first
75
+ browser signed in to the registry
76
+ --browser-import-scope <full|domains>
77
+ Import the whole profile, or only the
78
+ registry's sign-in cookies (default: full
79
+ for a named browser, domains otherwise)
70
80
  --browser-attach <mode> Fill forms in your own browser instead:
71
81
  snapshot[:<profile>] launches a temporary
72
82
  copy of your profile, extension drives your
@@ -100,11 +110,13 @@ export async function main(args = process.argv.slice(2)) {
100
110
  "no-browser": { type: "boolean", default: false },
101
111
  "open-with": { type: "string" },
102
112
  "keep-session": { type: "boolean", default: false },
113
+ manual: { type: "boolean", default: false },
103
114
  browser: { type: "string", default: "default" },
104
115
  "browser-channel": { type: "string", default: "chrome" },
105
116
  "browser-profile": { type: "string" },
106
117
  "browser-executable": { type: "string" },
107
118
  "browser-import": { type: "string" },
119
+ "browser-import-scope": { type: "string" },
108
120
  "browser-attach": { type: "string" },
109
121
  "browser-pref": { type: "string", multiple: true },
110
122
  "browser-restriction": { type: "string", multiple: true },
@@ -133,6 +145,7 @@ export async function main(args = process.argv.slice(2)) {
133
145
  channel: values["browser-channel"],
134
146
  executable: values["browser-executable"],
135
147
  importFrom: values["browser-import"],
148
+ importScope: values["browser-import-scope"],
136
149
  attach: values["browser-attach"],
137
150
  preferences: values["browser-pref"],
138
151
  restrictions: values["browser-restriction"],
@@ -168,6 +181,7 @@ export async function main(args = process.argv.slice(2)) {
168
181
  });
169
182
  const planOptions = {
170
183
  verifyRelease: values["verify-release"],
184
+ manual: values.manual,
171
185
  workflow: await workflowOverride(repository, values.workflow),
172
186
  publisherEnvironment: nonEmpty(values.environment, "--environment"),
173
187
  environment,
@@ -0,0 +1,423 @@
1
+ // The crates.io first publish through the crates.io API (#26): the automated
2
+ // browser holds the crates.io session, a short-lived token limited to one
3
+ // crate is created from inside the page, handed only to the `cargo publish`
4
+ // child process, used to attach the trusted publisher, then revoked and the
5
+ // revocation verified. The token never reaches the DOM, the terminal, the
6
+ // clipboard, or cargo's credentials file.
7
+ import { readFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ import { registryEndpoint, USER_AGENT } from "./registry-state.mjs";
11
+ import { offerSignInImport, signInDomains } from "./sign-in-import.mjs";
12
+ import { checkCratesToken, CRATES_TOKENS_URL } from "./tokens.mjs";
13
+
14
+ /** Where the automated browser signs in to crates.io. */
15
+ export const CRATES_IO_URL = "https://crates.io/";
16
+ /** Where a crates.io account verifies its email address. */
17
+ export const CRATES_PROFILE_URL = "https://crates.io/settings/profile";
18
+ /** How long the first-publish token lives, in milliseconds. */
19
+ export const TOKEN_LIFETIME_MS = 60 * 60_000;
20
+ /** The only endpoints the first-publish token may call. */
21
+ export const TOKEN_SCOPES = ["publish-new", "trusted-publishing"];
22
+ /** How long the sign-in waits for the maintainer, in milliseconds. */
23
+ export const SIGN_IN_TIMEOUT_MS = 10 * 60_000;
24
+
25
+ /** The `PUT /api/v1/me/tokens` body: one crate, two endpoints, one hour. */
26
+ export function tokenRequest(crate, now = new Date()) {
27
+ return {
28
+ api_token: {
29
+ name: `prm-first-publish-${crate}`,
30
+ crate_scopes: [crate],
31
+ endpoint_scopes: TOKEN_SCOPES,
32
+ expired_at: new Date(now.getTime() + TOKEN_LIFETIME_MS).toISOString(),
33
+ },
34
+ };
35
+ }
36
+
37
+ /** The `POST /api/v1/trusted_publishing/github_configs` body. */
38
+ export function githubConfigRequest(crate, publisher) {
39
+ return {
40
+ github_config: {
41
+ crate,
42
+ repository_owner: publisher.organization,
43
+ repository_name: publisher.repository,
44
+ workflow_filename: publisher.workflow,
45
+ environment: publisher.environment || null,
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * A script that calls the crates.io API from inside the page with its
52
+ * session cookie and returns `{ status, body }` to the tool, never to the DOM.
53
+ */
54
+ export function pageFetchScript(method, apiPath, body) {
55
+ const init = {
56
+ method,
57
+ credentials: "same-origin",
58
+ headers: { accept: "application/json" },
59
+ };
60
+ if (body !== undefined) {
61
+ init.headers["content-type"] = "application/json";
62
+ init.body = JSON.stringify(body);
63
+ }
64
+ return `(async () => {
65
+ const response = await fetch(${JSON.stringify(apiPath)}, ${JSON.stringify(init)});
66
+ const body = await response.json().catch(() => null);
67
+ return { status: response.status, body };
68
+ })()`;
69
+ }
70
+
71
+ /** Clicks crates.io's "Log in with GitHub" button; returns whether it found one. */
72
+ export const LOGIN_CLICK_SCRIPT = `(() => {
73
+ const button = [...document.querySelectorAll("button, a")].find((element) =>
74
+ /log\\s*in with github/i.test(element.textContent || ""),
75
+ );
76
+ if (button) {
77
+ button.click();
78
+ }
79
+ return Boolean(button);
80
+ })()`;
81
+
82
+ /** The first error detail of a crates.io answer. */
83
+ export function errorDetail(body) {
84
+ return String(body?.errors?.[0]?.detail ?? "no error detail");
85
+ }
86
+
87
+ /** Reads the crates.io session of the page from `GET /api/v1/me`. */
88
+ export async function readSession(page) {
89
+ const { status, body } = await page.evaluate(
90
+ pageFetchScript("GET", "/api/v1/me"),
91
+ );
92
+ return {
93
+ signedIn: status === 200,
94
+ login: body?.user?.login,
95
+ emailVerified: body?.user?.email_verified === true,
96
+ emailSent: body?.user?.email_verification_sent === true,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Signs the automated browser in to crates.io: an existing session is kept,
102
+ * otherwise "Log in with GitHub" is clicked and `/me` polled until it answers
103
+ * 200. crates.io lets only accounts with a verified email publish, so an
104
+ * unverified one stops here with the settings link.
105
+ */
106
+ export async function signIn(page, options = {}) {
107
+ await page.goto(CRATES_IO_URL);
108
+ let session = await readSession(page);
109
+ const signedInBefore = session.signedIn;
110
+ if (!session.signedIn) {
111
+ console.log(
112
+ " Not signed in to crates.io; finish the GitHub sign-in in the automated browser.",
113
+ );
114
+ if (!(await page.evaluate(LOGIN_CLICK_SCRIPT))) {
115
+ console.log(' Click "Log in with GitHub" on crates.io.');
116
+ }
117
+ const deadline = Date.now() + (options.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
118
+ const interval = options.pollIntervalMs ?? 2_000;
119
+ while (!session.signedIn) {
120
+ if (Date.now() > deadline) {
121
+ throw new Error("timed out waiting for the crates.io sign-in");
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, interval));
124
+ session = await readSession(page);
125
+ }
126
+ }
127
+ console.log(
128
+ ` Signed in to crates.io as ${session.login ?? "your account"}.`,
129
+ );
130
+ if (!session.emailVerified) {
131
+ const sent = session.emailSent
132
+ ? " A verification email was sent; follow its link,"
133
+ : " Add and verify an email address";
134
+ throw new Error(
135
+ `crates.io only lets accounts with a verified email address publish or attach trusted publishers.${sent} at ${CRATES_PROFILE_URL}, then re-run setup`,
136
+ );
137
+ }
138
+ return { ...session, signedInBefore };
139
+ }
140
+
141
+ /** The question asked once before the token is created. */
142
+ export function firstPublishQuestion(crate, version, publisher) {
143
+ const release = version ? `${crate} v${version}` : crate;
144
+ const attach = publisher
145
+ ? `, attach ${publisher.workflow} as trusted publisher`
146
+ : "";
147
+ return `Create a 1-hour token limited to crate ${crate} with publish-new + trusted-publishing, publish ${release}${attach}, revoke the token? [y/N] `;
148
+ }
149
+
150
+ /**
151
+ * Reads `version` from the `[package]` table of a Cargo.toml, or `undefined`
152
+ * when it is inherited from the workspace.
153
+ */
154
+ export async function crateVersion(directory) {
155
+ const text = await readFile(path.join(directory, "Cargo.toml"), "utf8").catch(
156
+ () => "",
157
+ );
158
+ let table = "";
159
+ for (const line of text.split(/\r?\n/)) {
160
+ const header = /^\s*\[([^\]]+)\]/.exec(line);
161
+ if (header) {
162
+ table = header[1].trim();
163
+ continue;
164
+ }
165
+ const value = /^\s*version\s*=\s*["']([^"']+)["']/.exec(line);
166
+ if (table === "package" && value) {
167
+ return value[1];
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /** Calls the crates.io API with the token from the tool, never from the page. */
174
+ async function tokenCall(base, token, method, apiPath, body, options) {
175
+ const fetcher = options.fetch ?? globalThis.fetch;
176
+ const url = `${base}${apiPath}`;
177
+ const headers = {
178
+ accept: "application/json",
179
+ authorization: token,
180
+ "user-agent": USER_AGENT,
181
+ };
182
+ if (body !== undefined) {
183
+ headers["content-type"] = "application/json";
184
+ }
185
+ try {
186
+ const response = await fetcher(url, {
187
+ method,
188
+ headers,
189
+ body: body === undefined ? undefined : JSON.stringify(body),
190
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
191
+ });
192
+ if (options.verbose) {
193
+ console.error(`${method} ${url} -> ${response.status}`);
194
+ }
195
+ const parsed = await response.json().catch(() => null);
196
+ return { status: response.status, body: parsed };
197
+ } catch (error) {
198
+ if (options.verbose) {
199
+ console.error(`${method} ${url} failed: ${error.message}`);
200
+ }
201
+ return { status: 0, body: { errors: [{ detail: error.message }] } };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Attaches the trusted publisher with the page's session cookie, for a crate
207
+ * that already exists or when the token could not. Returns whether it worked.
208
+ */
209
+ export async function attachWithSession(page, crate, publisher) {
210
+ const { status, body } = await page.evaluate(
211
+ pageFetchScript(
212
+ "POST",
213
+ "/api/v1/trusted_publishing/github_configs",
214
+ githubConfigRequest(crate, publisher),
215
+ ),
216
+ );
217
+ return reportAttach(status, body);
218
+ }
219
+
220
+ function reportAttach(status, body) {
221
+ if (status >= 200 && status < 300) {
222
+ console.log(" crates.io stored the trusted publisher.");
223
+ return true;
224
+ }
225
+ console.error(
226
+ `warning: crates.io did not attach the trusted publisher (${status}: ${errorDetail(body)})`,
227
+ );
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Publishes a crate for the first time through the crates.io API.
233
+ * `publish(env)` runs `cargo publish` with `env` added to its own environment
234
+ * only; `waitForRegistry()` polls until the crate is visible. The token is
235
+ * revoked in `finally`, and the run fails while crates.io still accepts it.
236
+ * Returns `{ attached }`, whether the trusted publisher was attached.
237
+ */
238
+ export async function firstPublish({
239
+ page,
240
+ crate,
241
+ publisher,
242
+ publish,
243
+ waitForRegistry,
244
+ now = new Date(),
245
+ env = process.env,
246
+ ...options
247
+ }) {
248
+ const created = await page.evaluate(
249
+ pageFetchScript("PUT", "/api/v1/me/tokens", tokenRequest(crate, now)),
250
+ );
251
+ // The token lives only in `secret` and is dropped once revoked.
252
+ const secret = { token: created.body?.api_token?.token };
253
+ if (created.status !== 200 || !secret.token) {
254
+ throw new Error(
255
+ `crates.io did not create the first-publish token (${created.status}: ${errorDetail(created.body)}); nothing was published`,
256
+ );
257
+ }
258
+ const tokenId = created.body.api_token.id;
259
+ console.log(
260
+ " Created a 1-hour first-publish token; it stays in this process only.",
261
+ );
262
+ const base = registryEndpoint("crates-io", env);
263
+ let attached = false;
264
+ let failure;
265
+ try {
266
+ const result = await publish({ CARGO_REGISTRY_TOKEN: secret.token });
267
+ if (result.code !== 0) {
268
+ throw new Error(`cargo exited with status ${result.code}`);
269
+ }
270
+ await waitForRegistry();
271
+ if (publisher) {
272
+ const answer = await tokenCall(
273
+ base,
274
+ secret.token,
275
+ "POST",
276
+ "/trusted_publishing/github_configs",
277
+ githubConfigRequest(crate, publisher),
278
+ options,
279
+ );
280
+ attached = reportAttach(answer.status, answer.body);
281
+ }
282
+ } catch (error) {
283
+ failure = error;
284
+ }
285
+ try {
286
+ await revoke(page, base, secret.token, tokenId, options);
287
+ } catch (error) {
288
+ throw failure
289
+ ? new Error(`${error.message}; the run failed before: ${failure.message}`)
290
+ : error;
291
+ } finally {
292
+ delete secret.token;
293
+ }
294
+ if (failure) {
295
+ throw failure;
296
+ }
297
+ return { attached };
298
+ }
299
+
300
+ /**
301
+ * Revokes the token with `DELETE /api/v1/tokens/current`, falls back to the
302
+ * page session, and verifies that crates.io rejects it.
303
+ */
304
+ async function revoke(page, base, token, tokenId, options) {
305
+ const answer = await tokenCall(
306
+ base,
307
+ token,
308
+ "DELETE",
309
+ "/tokens/current",
310
+ undefined,
311
+ options,
312
+ );
313
+ let state = await checkCratesToken(base, token, options);
314
+ if (state !== "revoked" && tokenId !== undefined) {
315
+ if (options.verbose) {
316
+ console.error(
317
+ `token revocation answered ${answer.status}; revoking through the browser session`,
318
+ );
319
+ }
320
+ await page
321
+ .evaluate(pageFetchScript("DELETE", `/api/v1/me/tokens/${tokenId}`))
322
+ .catch(() => undefined);
323
+ state = await checkCratesToken(base, token, options);
324
+ }
325
+ if (state === "revoked") {
326
+ console.log(" Revoked the first-publish token; crates.io rejects it now.");
327
+ return;
328
+ }
329
+ if (state === undefined) {
330
+ console.error(
331
+ `warning: crates.io gave no clear answer about the first-publish token; check that it is revoked at ${CRATES_TOKENS_URL}`,
332
+ );
333
+ return;
334
+ }
335
+ throw new Error(
336
+ `the first-publish token still authenticates on crates.io; revoke it at ${CRATES_TOKENS_URL}`,
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Runs a crates.io API step of a setup session: `crates-sign-in`,
342
+ * `first-publish`, `attach-trusted-publisher`, or the cleanup step
343
+ * `crates-sign-out`.
344
+ */
345
+ export async function runCratesApiStep(session, step) {
346
+ const { options, plan, conditions } = session;
347
+ if (options.noBrowser) {
348
+ throw new Error(
349
+ "the crates.io API steps drive the automated browser; drop --no-browser, or pass --manual for the manual checklist",
350
+ );
351
+ }
352
+ const crate = plan.package.name;
353
+ const domains = signInDomains(plan.registry);
354
+ let page = await session.automatedPage();
355
+ switch (step.id) {
356
+ case "crates-sign-in": {
357
+ await page.goto(CRATES_IO_URL);
358
+ if (!(await readSession(page)).signedIn) {
359
+ // Whether imported or signed in by hand, the sign-in is this run's.
360
+ conditions.add("crates-signed-in");
361
+ if (await offerSignInImport(session, domains)) {
362
+ page = await session.automatedPage();
363
+ }
364
+ }
365
+ await signIn(page, options);
366
+ return;
367
+ }
368
+ case "first-publish": {
369
+ const version = await crateVersion(session.cwd(step));
370
+ const publisher = plan.trusted_publisher;
371
+ if (step.confirm && !options.yes) {
372
+ const answer = await session.prompt(
373
+ firstPublishQuestion(crate, version, publisher),
374
+ );
375
+ if (!/^(?:y|yes)$/i.test(answer)) {
376
+ throw new Error(
377
+ "the first publish was declined; no token was created and nothing was published",
378
+ );
379
+ }
380
+ }
381
+ const { attached } = await firstPublish({
382
+ page,
383
+ crate,
384
+ publisher,
385
+ publish: (env) => session.runProcess(step, true, env),
386
+ waitForRegistry: () =>
387
+ session.wait({ id: "wait-for-registry", url: step.url }),
388
+ env: options.env ?? process.env,
389
+ fetch: options.fetch,
390
+ verbose: options.verbose,
391
+ });
392
+ if (attached) {
393
+ conditions.delete("trust-missing");
394
+ }
395
+ return;
396
+ }
397
+ case "attach-trusted-publisher":
398
+ if (!plan.trusted_publisher) {
399
+ console.log(
400
+ " No GitHub repository or release workflow is known, so the trusted publisher is configured in the form.",
401
+ );
402
+ } else if (await attachWithSession(page, crate, plan.trusted_publisher)) {
403
+ conditions.delete("trust-missing");
404
+ return;
405
+ }
406
+ conditions.add("trust-api-failed");
407
+ return;
408
+ case "crates-sign-out":
409
+ if (options.keepSession) {
410
+ console.log(
411
+ `Keeping the ${domains.join(" / ")} sign-in in the automated profile (--keep-session); the next run reuses it. No token is kept.`,
412
+ );
413
+ } else if (page.clearCookies) {
414
+ await page.clearCookies(domains);
415
+ console.log(
416
+ ` Removed the ${domains.join(" / ")} sign-in cookies from the automated profile.`,
417
+ );
418
+ }
419
+ return;
420
+ default:
421
+ throw new Error(`no crates.io API step ${step.id}`);
422
+ }
423
+ }
package/src/flows.mjs CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  registryEndpoint,
5
5
  registryStateUrl,
6
6
  } from "./registry-state.mjs";
7
+ import { CRATES_IO_URL } from "./crates-api.mjs";
7
8
  import { CRATES_TOKENS_URL, tokenSecretSteps } from "./tokens.mjs";
8
9
 
9
10
  /** Step conditions that only hold while a package is not yet published. */
@@ -15,6 +16,7 @@ export const BOOTSTRAP_CONDITIONS = new Set([
15
16
  /** Step conditions evaluated in the cleanup phase, after every other step. */
16
17
  export const CLEANUP_CONDITIONS = new Set([
17
18
  "tool-signed-in",
19
+ "crates-signed-in",
18
20
  "worktree-created",
19
21
  ]);
20
22
 
@@ -226,13 +228,15 @@ export function npmFlow(packageInfo, context) {
226
228
  const name = packageInfo.name;
227
229
  const registry = registryEndpoint("npm");
228
230
  const trustNpm = context.trustNpm ?? DEFAULT_TRUST_NPM;
231
+ // npm trust list needs a 2FA approval; with --json npm holds the approval
232
+ // URL back until it exits, so the human output is read instead (#24).
229
233
  const trustList = command("npx", [
230
234
  "-y",
231
235
  trustNpm,
232
236
  "trust",
233
237
  "list",
234
238
  name,
235
- "--json",
239
+ "--browser=false",
236
240
  ]);
237
241
  const steps = [
238
242
  step(
@@ -449,7 +453,85 @@ export function npmFlow(packageInfo, context) {
449
453
  return steps;
450
454
  }
451
455
 
456
+ /**
457
+ * The crates.io flow. By default the crates.io API publishes the first
458
+ * version with a short-lived token the tool creates, uses, and revokes
459
+ * itself (#26); `context.manual` keeps the manual token checklist.
460
+ */
452
461
  export function cratesFlow(packageInfo, context) {
462
+ return context.manual
463
+ ? manualCratesFlow(packageInfo, context)
464
+ : apiCratesFlow(packageInfo, context);
465
+ }
466
+
467
+ function apiCratesFlow(packageInfo, context) {
468
+ const name = packageInfo.name;
469
+ const api = registryStateUrl(packageInfo);
470
+ const steps = [
471
+ step(
472
+ "validate-package",
473
+ "Validate the crate",
474
+ "check",
475
+ "Package the crate without uploading it.",
476
+ { command: command("cargo", ["publish", "--dry-run"]) },
477
+ ),
478
+ checkRegistryStep("crates.io", api),
479
+ ...worktreeSteps(),
480
+ step(
481
+ "crates-sign-in",
482
+ "Sign in to crates.io in the automated browser",
483
+ "api",
484
+ 'Reuse the automated profile\'s crates.io session, or click "Log in with GitHub" and wait until GET /api/v1/me answers. crates.io needs a verified email address.',
485
+ { url: CRATES_IO_URL, when: "trust-missing" },
486
+ ),
487
+ step(
488
+ "first-publish",
489
+ "Publish the first version",
490
+ "api",
491
+ `After one confirmation, create a 1-hour token limited to the crate ${name} with the publish-new and trusted-publishing scopes from inside the crates.io page, run cargo publish from the clean checkout with the token only in its environment, wait for the registry, attach the trusted publisher with the token, then revoke it and verify crates.io rejects it. The token is never printed or stored.`,
492
+ {
493
+ command: command("cargo", ["publish"]),
494
+ url: api,
495
+ when: "package-missing",
496
+ cwd: packageCwd(context.directory),
497
+ confirm: true,
498
+ },
499
+ ),
500
+ step(
501
+ "attach-trusted-publisher",
502
+ "Attach the crates.io trusted publisher",
503
+ "api",
504
+ "POST the repository's GitHub Actions workflow to /api/v1/trusted_publishing/github_configs with the browser session.",
505
+ { when: "trust-missing" },
506
+ ),
507
+ step(
508
+ "configure-trusted-publisher",
509
+ "Configure crates.io trusted publishing",
510
+ "browser",
511
+ "The API could not attach the trusted publisher; add the repository's GitHub Actions workflow in the form, then review and submit.",
512
+ {
513
+ url: `https://crates.io/crates/${encodeURIComponent(name)}/settings/new-trusted-publisher`,
514
+ when: "trust-api-failed",
515
+ },
516
+ ),
517
+ ];
518
+ if (context.slug) {
519
+ steps.push(...tokenSecretSteps(packageInfo, context.slug));
520
+ }
521
+ steps.push(
522
+ step(
523
+ "crates-sign-out",
524
+ "Sign the automated browser out of crates.io",
525
+ "api",
526
+ "Remove the sign-in this run brought into the automated profile; --keep-session keeps it. No token outlives the run either way.",
527
+ { when: "crates-signed-in" },
528
+ ),
529
+ removeWorktreeStep(),
530
+ );
531
+ return steps;
532
+ }
533
+
534
+ function manualCratesFlow(packageInfo, context) {
453
535
  const name = packageInfo.name;
454
536
  const api = registryStateUrl(packageInfo);
455
537
  const steps = [
package/src/plan.mjs CHANGED
@@ -173,6 +173,7 @@ function flowPlan(inspection, packageInfo, options) {
173
173
  environment,
174
174
  verifyRelease: Boolean(options.verifyRelease),
175
175
  trustNpm: options.environment?.trustNpm,
176
+ manual: Boolean(options.manual),
176
177
  };
177
178
  const mode = planMode(packageInfo);
178
179
  let steps = FLOWS.get(packageInfo.registry)(packageInfo, context);
package/src/setup.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  withFreshLinks,
20
20
  } from "./approvals.mjs";
21
21
  import { connectAutomation } from "./automation.mjs";
22
+ import { runCratesApiStep } from "./crates-api.mjs";
22
23
  import {
23
24
  detachedRunner,
24
25
  npmPrefillScript,
@@ -31,6 +32,7 @@ import { packageDirectory } from "./plan.mjs";
31
32
  import { twoFactorMode } from "./prerequisites.mjs";
32
33
  import { protectLegacyProfile } from "./profile.mjs";
33
34
  import { getJson, probePackage } from "./registry-state.mjs";
35
+ import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
34
36
  import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
35
37
 
36
38
  export { defaultBrowserProfile } from "./profile.mjs";
@@ -43,6 +45,10 @@ const INTERACTIVE_CHECKS = new Set(["check-trust", "verify-trusted-publisher"]);
43
45
  const PAGES_CHANGES = new Set(["enable-pages", "use-pages-workflow"]);
44
46
  /** Checks whose failure is an answer, so their output is not echoed. */
45
47
  const QUIET_CHECKS = new Set(["check-sign-in", "check-pages"]);
48
+ /** A trusted publisher in `npm trust list` output, human-readable or JSON. */
49
+ const LISTED_PUBLISHER = /^\s*(?:id|type|file):\s*\S|"(?:id|type|file)"\s*:/m;
50
+ /** What `npm trust github` prints once the registry stored the publisher. */
51
+ const TRUST_CREATED = /Trust configuration created successfully/i;
46
52
  /** npm's answer when a workflow publishes without an attached trusted publisher. */
47
53
  const PUBLISH_REJECTED = /\bE404\b|404 Not Found|invalid-publisher/i;
48
54
  /** Where `--browser` opens URLs that need no automation. */
@@ -107,8 +113,7 @@ class SetupSession {
107
113
  this.conditions.add("trust-missing");
108
114
  }
109
115
  this.values = {};
110
- this.deferred = [];
111
- this.automation = null;
116
+ this.automation = options.automation ?? null;
112
117
  this.temporary = null;
113
118
  this.shim = null;
114
119
  }
@@ -116,7 +121,7 @@ class SetupSession {
116
121
  async run() {
117
122
  for (const step of this.plan.steps) {
118
123
  if (CLEANUP_CONDITIONS.has(step.when)) {
119
- this.deferred.push(step);
124
+ // cleanup() runs these, even after a failed step.
120
125
  } else if (!step.when || this.conditions.has(step.when)) {
121
126
  this.log(`==> ${step.title}`);
122
127
  await this.runStep(step);
@@ -126,12 +131,21 @@ class SetupSession {
126
131
  }
127
132
  }
128
133
 
134
+ /**
135
+ * Runs every cleanup step of the plan whose condition holds, including
136
+ * those after a step that failed (#24).
137
+ */
129
138
  async cleanup() {
130
- for (const step of this.deferred) {
139
+ for (const step of cleanupSteps(this.plan)) {
131
140
  if (step.id === "sign-out" && this.keepsSession(step)) {
132
141
  console.log(
133
142
  "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
143
  );
144
+ } else if (this.conditions.has(step.when) && step.kind === "api") {
145
+ this.log(`==> ${step.title}`);
146
+ await runCratesApiStep(this, step).catch((error) =>
147
+ console.error(`warning: ${step.id} failed: ${error.message}`),
148
+ );
135
149
  } else if (this.conditions.has(step.when)) {
136
150
  this.log(`==> ${step.title}`);
137
151
  const result = await this.runProcess(step, true);
@@ -179,6 +193,8 @@ class SetupSession {
179
193
  return this.wait(step);
180
194
  case "browser":
181
195
  return this.browser(step);
196
+ case "api":
197
+ return runCratesApiStep(this, step);
182
198
  default:
183
199
  console.log(step.description);
184
200
  if (step.url) {
@@ -226,8 +242,7 @@ class SetupSession {
226
242
  return;
227
243
  case "check-trust":
228
244
  case "verify-trusted-publisher": {
229
- const trusted =
230
- result.code === 0 && /"(?:id|type|file)"\s*:/.test(output);
245
+ const trusted = result.code === 0 && listsTrustedPublisher(output);
231
246
  toggle(this.conditions, "trust-missing", !trusted);
232
247
  if (step.id === "verify-trusted-publisher" && !trusted) {
233
248
  throw new Error(
@@ -483,7 +498,9 @@ class SetupSession {
483
498
  `${step.command.program} exited with status ${result.code}`,
484
499
  );
485
500
  }
486
- if (step.id === "sign-in") {
501
+ if (step.id === "attach-trusted-publisher") {
502
+ this.confirmTrustCreated(result.stdout);
503
+ } else if (step.id === "sign-in") {
487
504
  this.conditions.add("tool-signed-in");
488
505
  } else if (step.id === "prepare-worktree") {
489
506
  this.conditions.add("worktree-created");
@@ -497,6 +514,17 @@ class SetupSession {
497
514
  }
498
515
  }
499
516
 
517
+ /**
518
+ * npm prints the stored configuration once `npm trust github` succeeds, so
519
+ * that answer verifies it without a second 2FA-gated `npm trust list`.
520
+ */
521
+ confirmTrustCreated(output) {
522
+ if (TRUST_CREATED.test(stripAnsi(output))) {
523
+ console.log(" npm stored the trusted publisher.");
524
+ this.conditions.delete("trust-missing");
525
+ }
526
+ }
527
+
500
528
  recordPack(output) {
501
529
  const packed = packedEntry(output);
502
530
  for (const file of packed.files ?? []) {
@@ -606,12 +634,34 @@ class SetupSession {
606
634
  }
607
635
  return;
608
636
  }
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);
637
+ await (await this.automatedPage()).goto(url);
638
+ }
639
+
640
+ /**
641
+ * The automated page, launched once. The crates.io sign-in step offers
642
+ * `--browser-import default|auto` itself, once it knows a sign-in is missing.
643
+ */
644
+ async automatedPage(browser = this.options.browserOptions) {
645
+ if (!this.automation) {
646
+ const domains = signInDomains(this.plan.registry);
647
+ this.automation = await connectAutomation({
648
+ browser: await launchBrowser(browser, domains, {
649
+ deferred: this.plan.registry === "crates-io",
650
+ verbose: this.options.verbose,
651
+ }),
652
+ profile: this.options.browserProfile,
653
+ verbose: this.options.verbose,
654
+ domains,
655
+ });
656
+ }
657
+ return this.automation;
658
+ }
659
+
660
+ /** Relaunches the automated browser with other options, such as an import. */
661
+ async reconnect(browser) {
662
+ await this.automation?.close();
663
+ this.automation = null;
664
+ return this.automatedPage(browser);
615
665
  }
616
666
 
617
667
  /**
@@ -668,9 +718,10 @@ class SetupSession {
668
718
  return result;
669
719
  }
670
720
 
671
- async runProcess(step, mirror) {
721
+ /** Runs a step's command; `extra` is added to that child's environment only. */
722
+ async runProcess(step, mirror, extra = undefined) {
672
723
  const { command, cwd } = this.prepare(step);
673
- let env = process.env;
724
+ let env = extra ? { ...process.env, ...extra } : process.env;
674
725
  const npm = ["npm", "npx"].includes(command.program);
675
726
  if (npm) {
676
727
  await this.detectDefaultBrowser();
@@ -730,6 +781,20 @@ class SetupSession {
730
781
  }
731
782
  }
732
783
 
784
+ /** The plan's cleanup steps, which run after every other step, even a failed one. */
785
+ export function cleanupSteps(plan) {
786
+ return plan.steps.filter((step) => CLEANUP_CONDITIONS.has(step.when));
787
+ }
788
+
789
+ /** Whether `npm trust list` output names a trusted publisher. */
790
+ export function listsTrustedPublisher(output) {
791
+ return LISTED_PUBLISHER.test(stripAnsi(output));
792
+ }
793
+
794
+ function stripAnsi(text) {
795
+ return String(text ?? "").replaceAll(ANSI, "");
796
+ }
797
+
733
798
  /**
734
799
  * npm's warnings from `npm pack`, such as fields it auto-corrected or removed
735
800
  * from the packed package.json, without npm's advice to run its fixer, which
@@ -0,0 +1,266 @@
1
+ // Reusing a registry sign-in from an installed browser (#26): the automated
2
+ // profile stays fresh and clean by default; when a step needs a sign-in it
3
+ // does not hold, the tool offers to import only the cookies of the registry's
4
+ // sign-in domains from a browser that has them. Cookie values are never
5
+ // printed, logged, or cached: they are only counted.
6
+ import * as commander from "browser-commander";
7
+
8
+ import { detectDefaultBrowser } from "./default-browser.mjs";
9
+
10
+ /** Browser Commander's cookie sources, should a release stop exporting them. */
11
+ const FALLBACK_SOURCES = ["chrome", "edge", "brave", "chromium", "firefox"];
12
+ /** `--browser-import` values that pick the source browser for the user. */
13
+ export const IMPORT_CHOICES = ["default", "auto"];
14
+ /** `--browser-import-scope` values. */
15
+ export const IMPORT_SCOPES = ["domains", "full"];
16
+
17
+ /** The domains whose cookies hold each registry's sign-in. */
18
+ export const SIGN_IN_DOMAINS = {
19
+ "crates-io": ["crates.io", "github.com"],
20
+ npm: ["npmjs.com"],
21
+ pypi: ["pypi.org", "github.com"],
22
+ };
23
+
24
+ /** Display names of the source browsers, as the default-browser query names them. */
25
+ const SOURCE_NAMES = {
26
+ chrome: "Google Chrome",
27
+ edge: "Microsoft Edge",
28
+ brave: "Brave",
29
+ chromium: "Chromium",
30
+ firefox: "Firefox",
31
+ };
32
+
33
+ /**
34
+ * Every browser `--browser-import` can read, from Browser Commander. Safari,
35
+ * Opera, Vivaldi, and Arc follow once Browser Commander reads them
36
+ * (link-foundation/browser-commander#114).
37
+ */
38
+ export function importSources() {
39
+ return [...(commander.SUPPORTED_COOKIE_BROWSERS ?? FALLBACK_SOURCES)];
40
+ }
41
+
42
+ /** The sign-in domains of a registry, or none. */
43
+ export function signInDomains(registry) {
44
+ return SIGN_IN_DOMAINS[registry] ?? [];
45
+ }
46
+
47
+ /** The display name of a source browser. */
48
+ export function sourceName(browser) {
49
+ return SOURCE_NAMES[browser] ?? browser;
50
+ }
51
+
52
+ /** The source id of a default-browser display name, when it can be imported. */
53
+ export function sourceId(displayName) {
54
+ const entry = Object.entries(SOURCE_NAMES).find(
55
+ ([, name]) => name === displayName,
56
+ );
57
+ return entry && importSources().includes(entry[0]) ? entry[0] : undefined;
58
+ }
59
+
60
+ /**
61
+ * The migration Browser Commander runs: the whole profile for `full`, or
62
+ * only the cookies of `domains`.
63
+ */
64
+ export function migrationFor(source, scope, domains) {
65
+ const migration = { browser: source.browser };
66
+ if (source.profile) {
67
+ migration.profile = source.profile;
68
+ }
69
+ if (scope === "domains" && domains.length > 0) {
70
+ migration.include = ["cookies"];
71
+ migration.domains = domains;
72
+ }
73
+ return migration;
74
+ }
75
+
76
+ /**
77
+ * Lists the installed browser profiles that hold cookies for any of
78
+ * `domains`, the default browser first. Each entry is
79
+ * `{ browser, profile, label, domains }`; cookie values are discarded.
80
+ */
81
+ export async function findSignInSources(domains, options = {}) {
82
+ const list = options.listProfiles ?? commander.listBrowserProfiles;
83
+ const read = options.readCookies ?? commander.readBrowserCookies;
84
+ const preferred = options.defaultSource;
85
+ const browsers = (options.sources ?? importSources()).toSorted(
86
+ (left, right) => Number(right === preferred) - Number(left === preferred),
87
+ );
88
+ const found = [];
89
+ for (const browser of browsers) {
90
+ const profiles = await list({ browser }).catch(() => []);
91
+ for (const profile of profiles) {
92
+ const matched = [];
93
+ for (const domain of domains) {
94
+ const cookies = await read({
95
+ browser,
96
+ profile: profile.path,
97
+ domainFilter: domain,
98
+ ignoreDecryptionErrors: true,
99
+ cache: false,
100
+ }).catch((error) => {
101
+ if (options.verbose) {
102
+ console.error(
103
+ `could not read ${browser} cookies for ${domain}: ${error.message}`,
104
+ );
105
+ }
106
+ return [];
107
+ });
108
+ if (cookies.length > 0) {
109
+ matched.push(domain);
110
+ }
111
+ }
112
+ if (matched.length > 0) {
113
+ const name = profile.displayName ?? profile.name;
114
+ found.push({
115
+ browser,
116
+ profile: profile.path,
117
+ label:
118
+ profiles.length > 1
119
+ ? `${sourceName(browser)} (${name})`
120
+ : sourceName(browser),
121
+ domains: matched,
122
+ });
123
+ }
124
+ }
125
+ }
126
+ return found;
127
+ }
128
+
129
+ /** The source id of the system default browser, when it can be imported. */
130
+ export async function defaultSource(options = {}) {
131
+ const name =
132
+ options.defaultBrowserName ??
133
+ (await detectDefaultBrowser({ verbose: options.verbose }));
134
+ return name ? sourceId(name) : undefined;
135
+ }
136
+
137
+ /**
138
+ * Asks once which installed browser's sign-in to import, the default browser
139
+ * first; `auto` takes it without asking. Returns the chosen source or
140
+ * `undefined`.
141
+ */
142
+ export async function chooseSignInSource(sources, domains, ask, auto) {
143
+ if (sources.length === 0) {
144
+ return undefined;
145
+ }
146
+ const [first] = sources;
147
+ const question = `Import your ${domains.join(" / ")} sign-in from ${first.label} into the automated profile?`;
148
+ if (auto) {
149
+ console.log(` ${question} yes (--browser-import auto)`);
150
+ return first;
151
+ }
152
+ if (sources.length > 1) {
153
+ sources.forEach((source, index) =>
154
+ console.log(
155
+ ` ${index + 1}. ${source.label} (${source.domains.join(", ")})`,
156
+ ),
157
+ );
158
+ }
159
+ const choices = sources.length > 1 ? `[Y/n/1-${sources.length}]` : "[Y/n]";
160
+ const answer = String(await ask(`${question} ${choices} `)).trim();
161
+ if (/^(?:n|no)$/i.test(answer)) {
162
+ return undefined;
163
+ }
164
+ const index = Number(answer);
165
+ return Number.isInteger(index) && index >= 1 && index <= sources.length
166
+ ? sources[index - 1]
167
+ : first;
168
+ }
169
+
170
+ /**
171
+ * Resolves `--browser-import default|auto` to an installed source browser:
172
+ * the system default browser, or for `auto` the first browser with a
173
+ * sign-in for `domains`. Throws when `default` names a browser Browser
174
+ * Commander cannot read yet, such as Safari.
175
+ */
176
+ export async function resolveImportChoice(choice, domains, options = {}) {
177
+ const preferred = await defaultSource(options);
178
+ if (choice === "default") {
179
+ if (!preferred) {
180
+ throw new Error(
181
+ "--browser-import default: the default browser cannot be imported yet (Browser Commander reads " +
182
+ `${importSources().join(", ")}; see link-foundation/browser-commander#114)`,
183
+ );
184
+ }
185
+ return { browser: preferred, profile: null };
186
+ }
187
+ const [found] = await findSignInSources(domains, {
188
+ ...options,
189
+ defaultSource: preferred,
190
+ });
191
+ return found ? { browser: found.browser, profile: found.profile } : null;
192
+ }
193
+
194
+ /**
195
+ * The browser options a launch uses: `--browser-import default|auto` is
196
+ * resolved to an installed browser, except where a step offers the import
197
+ * once it knows the sign-in is missing (`deferred`).
198
+ */
199
+ export async function launchBrowser(browser, domains, options = {}) {
200
+ const choice = browser?.import?.browser;
201
+ if (!IMPORT_CHOICES.includes(choice)) {
202
+ return browser;
203
+ }
204
+ const source = options.deferred
205
+ ? null
206
+ : await resolveImportChoice(choice, domains, options);
207
+ if (!source && !options.deferred) {
208
+ console.error(
209
+ `warning: no installed browser holds a sign-in for ${domains.join(", ") || "this registry"}; the automated profile starts without one`,
210
+ );
211
+ }
212
+ return { ...browser, import: source };
213
+ }
214
+
215
+ /**
216
+ * Offers, once, to import the sign-in for `domains` from an installed browser
217
+ * into the automated profile, then relaunches it with the import. Only a
218
+ * dedicated profile is offered the import, and only without a named
219
+ * `--browser-import`, which already migrated at launch. Returns whether the
220
+ * sign-in was imported.
221
+ */
222
+ export async function offerSignInImport(session, domains) {
223
+ const browser = session.options.browserOptions;
224
+ const choice = browser?.import?.browser;
225
+ if (
226
+ !browser ||
227
+ browser.attach ||
228
+ (choice && !IMPORT_CHOICES.includes(choice))
229
+ ) {
230
+ return false;
231
+ }
232
+ const options = {
233
+ verbose: session.options.verbose,
234
+ ...session.options.importDiscovery,
235
+ };
236
+ const preferred = await defaultSource(options);
237
+ const sources =
238
+ choice === "default"
239
+ ? [preferred].filter(Boolean).map((id) => ({
240
+ browser: id,
241
+ profile: null,
242
+ label: sourceName(id),
243
+ domains,
244
+ }))
245
+ : await findSignInSources(domains, {
246
+ ...options,
247
+ defaultSource: preferred,
248
+ });
249
+ const source = await chooseSignInSource(
250
+ sources,
251
+ domains,
252
+ (message) => session.prompt(message),
253
+ Boolean(choice),
254
+ );
255
+ if (!source) {
256
+ return false;
257
+ }
258
+ await session.reconnect({
259
+ ...browser,
260
+ import: { browser: source.browser, profile: source.profile },
261
+ });
262
+ console.log(
263
+ ` Imported the ${domains.join(" / ")} sign-in from ${source.label}.`,
264
+ );
265
+ return true;
266
+ }