package-registry-manager 0.21.1 → 0.22.2

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/CHANGELOG.md CHANGED
@@ -35,6 +35,7 @@ currently shares its release version with the Rust implementation.
35
35
  outside any repository (`~/Library/Application Support` on macOS,
36
36
  `$XDG_STATE_HOME` or `~/.local/state` on Linux, `%LOCALAPPDATA%` on Windows,
37
37
  each followed by `package-registry-manager/browser-profile`) (#8).
38
+ - `browser-commander` is required at `^0.22.0`, its latest release (#28).
38
39
 
39
40
  ### Fixed
40
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.21.1",
3
+ "version": "0.22.2",
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/direct-execution.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 .",
@@ -39,7 +39,7 @@
39
39
  "pack:check": "npm pack --dry-run"
40
40
  },
41
41
  "dependencies": {
42
- "browser-commander": "^0.21.2",
42
+ "browser-commander": "^0.22.0",
43
43
  "command-stream": "^1.3.0",
44
44
  "playwright": "^1.63.0"
45
45
  },
@@ -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
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, realpathSync } from "node:fs";
3
+ import { readFileSync } from "node:fs";
4
4
  import { stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
- import { fileURLToPath } from "node:url";
8
7
  import { parseArgs } from "node:util";
9
8
 
10
9
  import { parseBrowserOptions } from "./browser-options.mjs";
10
+ import { isDirectExecution } from "./direct-execution.mjs";
11
11
  import { inspectRepository } from "./discovery.mjs";
12
12
  import { parseRegistry } from "./model.mjs";
13
13
  import { buildPlans } from "./plan.mjs";
@@ -15,6 +15,8 @@ import { probeEnvironment, renderPrerequisites } from "./prerequisites.mjs";
15
15
  import { probeRegistryState } from "./registry-state.mjs";
16
16
  import { BROWSER_MODES, defaultBrowserProfile, executePlan } from "./setup.mjs";
17
17
 
18
+ export { isDirectExecution };
19
+
18
20
  const HELP = `Usage: package-registry-manager-js [global options] <command>
19
21
 
20
22
  Commands:
@@ -50,9 +52,13 @@ Setup options:
50
52
  --open-with <app> Open sign-in and approval pages in this
51
53
  application instead of the default browser,
52
54
  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
55
+ --manual crates.io: create, use, and revoke the
56
+ first-publish token by hand instead of
57
+ through the crates.io API
58
+ --keep-session Stay signed in after setup: npm keeps its
59
+ session token in npm's user configuration
60
+ until npm logout; crates.io keeps only the
61
+ automated profile's browser session
56
62
  --browser <default|automated> Open sign-in and approval pages in your
57
63
  default browser, or in the automated
58
64
  profile too (default: default)
@@ -63,10 +69,16 @@ Setup options:
63
69
  instead of the channel's
64
70
  --browser-profile <path> Dedicated automation profile, used to fill
65
71
  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
72
+ --browser-import <browser>[:<profile>]|default|auto
73
+ Copy data from your chrome, edge, brave,
74
+ chromium, or firefox profile into the
75
+ automated profile first; default takes the
76
+ system default browser, auto the first
77
+ browser signed in to the registry
78
+ --browser-import-scope <full|domains>
79
+ Import the whole profile, or only the
80
+ registry's sign-in cookies (default: full
81
+ for a named browser, domains otherwise)
70
82
  --browser-attach <mode> Fill forms in your own browser instead:
71
83
  snapshot[:<profile>] launches a temporary
72
84
  copy of your profile, extension drives your
@@ -100,11 +112,13 @@ export async function main(args = process.argv.slice(2)) {
100
112
  "no-browser": { type: "boolean", default: false },
101
113
  "open-with": { type: "string" },
102
114
  "keep-session": { type: "boolean", default: false },
115
+ manual: { type: "boolean", default: false },
103
116
  browser: { type: "string", default: "default" },
104
117
  "browser-channel": { type: "string", default: "chrome" },
105
118
  "browser-profile": { type: "string" },
106
119
  "browser-executable": { type: "string" },
107
120
  "browser-import": { type: "string" },
121
+ "browser-import-scope": { type: "string" },
108
122
  "browser-attach": { type: "string" },
109
123
  "browser-pref": { type: "string", multiple: true },
110
124
  "browser-restriction": { type: "string", multiple: true },
@@ -133,6 +147,7 @@ export async function main(args = process.argv.slice(2)) {
133
147
  channel: values["browser-channel"],
134
148
  executable: values["browser-executable"],
135
149
  importFrom: values["browser-import"],
150
+ importScope: values["browser-import-scope"],
136
151
  attach: values["browser-attach"],
137
152
  preferences: values["browser-pref"],
138
153
  restrictions: values["browser-restriction"],
@@ -168,6 +183,7 @@ export async function main(args = process.argv.slice(2)) {
168
183
  });
169
184
  const planOptions = {
170
185
  verifyRelease: values["verify-release"],
186
+ manual: values.manual,
171
187
  workflow: await workflowOverride(repository, values.workflow),
172
188
  publisherEnvironment: nonEmpty(values.environment, "--environment"),
173
189
  environment,
@@ -407,22 +423,6 @@ function packageVersion() {
407
423
  return JSON.parse(readFileSync(manifest, "utf8")).version;
408
424
  }
409
425
 
410
- /**
411
- * Whether this module is the program node started. npm, npx, and global
412
- * installs start bins through a symlink in node_modules/.bin, while
413
- * import.meta.url names the real file, so both sides are resolved first.
414
- */
415
- export function isDirectExecution(moduleUrl, entryPath) {
416
- if (!entryPath) {
417
- return false;
418
- }
419
- try {
420
- return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPath);
421
- } catch {
422
- return false;
423
- }
424
- }
425
-
426
426
  if (isDirectExecution(import.meta.url, process.argv[1])) {
427
427
  main().catch((error) => {
428
428
  process.stderr.write(`error: ${error.message}\n`);
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Whether this module is the program node started. npm, npx, and global
6
+ * installs start bins through a symlink in node_modules/.bin, while
7
+ * import.meta.url names the real file, so both sides are resolved first.
8
+ */
9
+ export function isDirectExecution(moduleUrl, entryPath) {
10
+ if (!entryPath) {
11
+ return false;
12
+ }
13
+ try {
14
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPath);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
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
 
@@ -451,7 +453,85 @@ export function npmFlow(packageInfo, context) {
451
453
  return steps;
452
454
  }
453
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
+ */
454
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) {
455
535
  const name = packageInfo.name;
456
536
  const api = registryStateUrl(packageInfo);
457
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";
@@ -111,7 +113,7 @@ class SetupSession {
111
113
  this.conditions.add("trust-missing");
112
114
  }
113
115
  this.values = {};
114
- this.automation = null;
116
+ this.automation = options.automation ?? null;
115
117
  this.temporary = null;
116
118
  this.shim = null;
117
119
  }
@@ -139,6 +141,11 @@ class SetupSession {
139
141
  console.log(
140
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.",
141
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
+ );
142
149
  } else if (this.conditions.has(step.when)) {
143
150
  this.log(`==> ${step.title}`);
144
151
  const result = await this.runProcess(step, true);
@@ -186,6 +193,8 @@ class SetupSession {
186
193
  return this.wait(step);
187
194
  case "browser":
188
195
  return this.browser(step);
196
+ case "api":
197
+ return runCratesApiStep(this, step);
189
198
  default:
190
199
  console.log(step.description);
191
200
  if (step.url) {
@@ -625,12 +634,34 @@ class SetupSession {
625
634
  }
626
635
  return;
627
636
  }
628
- this.automation ??= await connectAutomation({
629
- browser: this.options.browserOptions,
630
- profile: this.options.browserProfile,
631
- verbose: this.options.verbose,
632
- });
633
- 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);
634
665
  }
635
666
 
636
667
  /**
@@ -687,9 +718,10 @@ class SetupSession {
687
718
  return result;
688
719
  }
689
720
 
690
- 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) {
691
723
  const { command, cwd } = this.prepare(step);
692
- let env = process.env;
724
+ let env = extra ? { ...process.env, ...extra } : process.env;
693
725
  const npm = ["npm", "npx"].includes(command.program);
694
726
  if (npm) {
695
727
  await this.detectDefaultBrowser();
@@ -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
+ }