package-registry-manager 0.22.10 → 0.22.11

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.22.10",
3
+ "version": "0.22.11",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/29808e088ac8fd8374361920bbebab07a4d63ac6",
44
- "browser-commander": "^0.31.0",
45
- "command-stream": "^2.1.0",
44
+ "browser-commander": "^0.31.2",
45
+ "command-stream": "^2.4.0",
46
46
  "playwright": "^1.63.0",
47
47
  "selenium-webdriver": "^4.50.0"
48
48
  },
package/src/auth-urls.mjs CHANGED
@@ -111,6 +111,7 @@ export function runInteractive(command, options) {
111
111
  shell: false,
112
112
  });
113
113
  let legacyLogin = false;
114
+ let openings = Promise.resolve();
114
115
  const stop = options.stopOnLegacyLogin
115
116
  ? () => {
116
117
  legacyLogin = true;
@@ -119,7 +120,11 @@ export function runInteractive(command, options) {
119
120
  : undefined;
120
121
  const scan =
121
122
  options.onUrl || stop
122
- ? authUrlScanner(options.onUrl ?? (() => {}), stop)
123
+ ? authUrlScanner((url, kind) => {
124
+ openings = openings.then(() => options.onUrl?.(url, kind));
125
+ // Observe rejection until the command closes and propagates it.
126
+ openings.catch(() => {});
127
+ }, stop)
123
128
  : null;
124
129
  let stdout = "";
125
130
  child.stdout.setEncoding("utf8");
@@ -141,9 +146,20 @@ export function runInteractive(command, options) {
141
146
  });
142
147
  }
143
148
  child.on("error", reject);
144
- child.on("close", (code) => {
149
+ child.on("close", async (code) => {
145
150
  scan?.("\n");
146
- resolve({ code: code ?? 1, stdout, legacyLogin, approvalExpired });
151
+ try {
152
+ await openings;
153
+ resolve({
154
+ code: code ?? 1,
155
+ stdout,
156
+ stderr: errors,
157
+ legacyLogin,
158
+ approvalExpired,
159
+ });
160
+ } catch (error) {
161
+ reject(error);
162
+ }
147
163
  });
148
164
  });
149
165
  }
package/src/browser.mjs CHANGED
@@ -74,7 +74,13 @@ export function npmPrefillScript(prefill, submit = false) {
74
74
  const config = ${JSON.stringify(prefill)};
75
75
  const shouldSubmit = ${JSON.stringify(submit)};
76
76
  const normalized = value => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
77
- const controls = Array.from(document.querySelectorAll('input, select, textarea'));
77
+ const provider = Array.from(document.querySelectorAll('button, label, [role="radio"]'))
78
+ .find(element => normalized(element.textContent) === 'github');
79
+ if (provider) provider.click();
80
+ const githubForm = document.querySelector('#pending-github-publisher-form, #github-publisher-form');
81
+ const controls = Array.from((githubForm || document).querySelectorAll('input, select, textarea'))
82
+ .filter(control => !control.disabled && control.type !== 'hidden'
83
+ && (githubForm || control.getClientRects().length > 0));
78
84
  const setValue = (control, value) => {
79
85
  const prototype = control instanceof HTMLSelectElement
80
86
  ? HTMLSelectElement.prototype
@@ -88,7 +94,7 @@ export function npmPrefillScript(prefill, submit = false) {
88
94
  };
89
95
  const textFor = control => {
90
96
  const explicit = control.id
91
- ? document.querySelector(\`label[for="\${CSS.escape(control.id)}"]\`)?.textContent
97
+ ? (control.closest('form') || document).querySelector(\`label[for="\${CSS.escape(control.id)}"]\`)?.textContent
92
98
  : '';
93
99
  return normalized([explicit, control.getAttribute('aria-label'), control.name,
94
100
  control.placeholder, control.closest('label')?.textContent].filter(Boolean).join(' '));
@@ -103,19 +109,18 @@ export function npmPrefillScript(prefill, submit = false) {
103
109
  const filled = [];
104
110
  const used = new Set();
105
111
  for (const [labels, value] of values) {
106
- if (!value) continue;
112
+ if (!value && labels[0] !== 'environment') continue;
107
113
  const control = controls.find(candidate => !used.has(candidate)
108
114
  && labels.some(label => textFor(candidate).includes(label)));
109
115
  if (control) { setValue(control, value); used.add(control); filled.push(labels[0]); }
110
116
  }
111
- const provider = Array.from(document.querySelectorAll('button, label, [role="radio"]'))
112
- .find(element => normalized(element.textContent).includes('github'));
113
- if (provider && filled.length === 0) provider.click();
114
117
  let submitted = false;
115
- if (shouldSubmit && filled.length >= 3) {
116
- const button = Array.from(document.querySelectorAll('button, input[type="submit"]'))
118
+ const required = ['organization', 'repository', 'workflow', ...(githubForm?.id.startsWith('pending-') ? ['project'] : [])];
119
+ const form = githubForm || controls.find(control => /repository|repo/.test(textFor(control)))?.closest('form');
120
+ if (shouldSubmit && required.every(field => filled.includes(field))) {
121
+ const button = Array.from((form || document).querySelectorAll('button, input[type="submit"]'))
117
122
  .find(candidate => /add|save|configure|submit/.test(normalized(candidate.textContent || candidate.value))
118
- && !candidate.disabled);
123
+ && !candidate.disabled && !candidate.classList.contains('button--disabled'));
119
124
  if (button) { button.click(); submitted = true; }
120
125
  }
121
126
  return { filled, providerSelected: Boolean(provider), submitted };
package/src/cli-main.mjs CHANGED
@@ -67,11 +67,11 @@ Setup options:
67
67
  through the crates.io API
68
68
  --keep-session Stay signed in after setup: npm keeps its
69
69
  session token in npm's user configuration
70
- until npm logout; crates.io keeps only the
71
- automated profile's browser session
70
+ until npm logout; the shared browser profile
71
+ keeps registry sessions across packages
72
72
  --browser <default|automated> Open sign-in and approval pages in your
73
- default browser, or in the automated
74
- profile too (default: default)
73
+ default browser, or in the automated profile;
74
+ npm reuses the automated profile
75
75
  --browser-channel <channel> Installed browser channel (default: chrome):
76
76
  ${launchChannels().join(", ")}
77
77
  --browser-executable <path> Installed browser executable to launch
@@ -83,7 +83,7 @@ Setup options:
83
83
  ${importSources().join(", ")}
84
84
  into the automated profile; default takes the
85
85
  system default browser, auto the first
86
- browser signed in to the registry
86
+ browser with registry cookies or saved logins
87
87
  --browser-import-scope <full|domains>
88
88
  Import the whole profile, or only the
89
89
  registry's sign-in cookies (default: full
package/src/flows.mjs CHANGED
@@ -346,7 +346,7 @@ export function npmFlow(packageInfo, context) {
346
346
  "sign-in",
347
347
  "Sign in to npm in the browser",
348
348
  "command",
349
- "Start a web login; the tool opens the printed URL in your default browser, where you are usually already signed in and only approve. No token is created or read by the tool.",
349
+ "Start a web login in the dedicated browser profile reused for publish approvals and publisher forms. Complete sign-in and 2FA in that browser. No token is created or read by the tool.",
350
350
  {
351
351
  command: command("npm", [
352
352
  "login",
@@ -1,4 +1,5 @@
1
1
  import { npmName, registryEndpoint, USER_AGENT } from "./registry-state.mjs";
2
+ import { checkSimilarity, namingError } from "./npm-similarity.mjs";
2
3
 
3
4
  /** Bounded punctuation variants, including npm's punctuation-free moniker. */
4
5
  export function nameVariants(name) {
@@ -52,18 +53,17 @@ export async function checkNamePolicy(name, options = {}) {
52
53
  );
53
54
  }
54
55
  if (variant !== name) {
55
- throw new Error(
56
- `npm name '${name}' is similar to existing '${variant}'; use @owner/${name} or a longer descriptive name`,
57
- );
56
+ throw await namingError(name, variant, options);
58
57
  }
59
58
  return;
60
59
  }
60
+ await checkSimilarity(name, options);
61
61
  }
62
62
 
63
63
  /** Report npm policy refusal once, without entering authentication retry logic. */
64
64
  export function policyRefusal(output) {
65
65
  return (
66
66
  /\bE403\b|403 Forbidden/i.test(output) &&
67
- /too similar|forbidden|blocked|not allowed/i.test(output)
67
+ /too similar|package name.*(?:forbidden|blocked|not allowed)/i.test(output)
68
68
  );
69
69
  }
@@ -0,0 +1,153 @@
1
+ import { npmName, registryEndpoint, USER_AGENT } from "./registry-state.mjs";
2
+
3
+ /** Compare punctuation-free monikers and a bounded Damerau edit distance. */
4
+ export function similarName(name, candidate) {
5
+ if (name.startsWith("@") || candidate.startsWith("@")) {
6
+ return false;
7
+ }
8
+ const normalize = (value) => value.toLowerCase().replaceAll(/[._-]/g, "");
9
+ const a = normalize(name),
10
+ b = normalize(candidate);
11
+ if (a === b) {
12
+ return true;
13
+ }
14
+ if (Math.abs(a.length - b.length) > 2 || a.length > 214 || b.length > 214) {
15
+ return false;
16
+ }
17
+ const rows = Array.from({ length: a.length + 1 }, (_, i) =>
18
+ Array.from({ length: b.length + 1 }, (_, j) =>
19
+ i === 0 ? j : j === 0 ? i : 0,
20
+ ),
21
+ );
22
+ for (let i = 1; i <= a.length; i += 1) {
23
+ for (let j = 1; j <= b.length; j += 1) {
24
+ rows[i][j] = Math.min(
25
+ rows[i - 1][j] + 1,
26
+ rows[i][j - 1] + 1,
27
+ rows[i - 1][j - 1] + Number(a[i - 1] !== b[j - 1]),
28
+ );
29
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
30
+ rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
31
+ }
32
+ }
33
+ }
34
+ return (
35
+ rows[a.length][b.length] <= (Math.min(a.length, b.length) >= 6 ? 2 : 1)
36
+ );
37
+ }
38
+
39
+ async function lookup(base, route, options) {
40
+ return (options.fetch ?? globalThis.fetch)(`${base}/${route}`, {
41
+ headers: {
42
+ accept: "application/json",
43
+ "cache-control": "no-cache, no-store",
44
+ "user-agent": USER_AGENT,
45
+ },
46
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
47
+ });
48
+ }
49
+
50
+ /** Check a finite set of proposed names; only a 404 means currently free. */
51
+ export async function freeAlternatives(name, options = {}) {
52
+ const leaf = name.split("/").at(-1);
53
+ const scope = options.npmScope?.replace(/^@/, "");
54
+ const candidates = [
55
+ ...new Set([
56
+ ...(options.nameAlternatives ?? []),
57
+ ...(scope ? [`@${scope}/${leaf}`] : []),
58
+ `${leaf}-package`,
59
+ `${leaf}-toolkit`,
60
+ ]),
61
+ ]
62
+ .filter((value) => value !== name)
63
+ .slice(0, 8);
64
+ const base = registryEndpoint("npm", options.env ?? process.env);
65
+ const free = [];
66
+ for (const candidate of candidates) {
67
+ if (
68
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(candidate) ||
69
+ candidate.length > 214
70
+ ) {
71
+ continue;
72
+ }
73
+ try {
74
+ if (
75
+ (await lookup(base, `${npmName(candidate)}?_=${Date.now()}`, options))
76
+ .status === 404
77
+ ) {
78
+ free.push(candidate);
79
+ }
80
+ } catch {
81
+ /* Unknown availability is never reported as free. */
82
+ }
83
+ }
84
+ return free;
85
+ }
86
+
87
+ /** A naming error includes verified alternatives without requesting credentials. */
88
+ export async function namingError(name, conflicting, options = {}) {
89
+ const free = await freeAlternatives(name, options);
90
+ return new Error(
91
+ `npm name '${name}' may be refused as too similar to existing '${conflicting}'; ${free.length ? `currently free alternatives: ${free.join(", ")} (scope ownership and npm policy still apply)` : "choose your own npm scope or a longer descriptive name; availability could not be confirmed"}. Rename package.json and retry; no authentication retry was attempted.`,
92
+ );
93
+ }
94
+
95
+ /** Public npm search is a bounded heuristic, not the private registry policy. */
96
+ export async function checkSimilarity(name, options = {}) {
97
+ if (name.startsWith("@")) {
98
+ return;
99
+ }
100
+ const base = registryEndpoint("npm", options.env ?? process.env);
101
+ // npm search does not always correct typos; verify common public monikers.
102
+ for (const candidate of [
103
+ "lodash",
104
+ "express",
105
+ "react",
106
+ "typescript",
107
+ "webpack",
108
+ "eslint",
109
+ "prettier",
110
+ "commander",
111
+ "axios",
112
+ "chalk",
113
+ "debug",
114
+ "dotenv",
115
+ "yargs",
116
+ "semver",
117
+ "glob",
118
+ "minimist",
119
+ "moment",
120
+ "jquery",
121
+ "rxjs",
122
+ "tslib",
123
+ ]) {
124
+ if (
125
+ similarName(name, candidate) &&
126
+ (await lookup(base, candidate, options)).ok
127
+ ) {
128
+ throw await namingError(name, candidate, options);
129
+ }
130
+ }
131
+ const queries = [...new Set([name, name.split(/[._-]/).at(-1)])].filter(
132
+ Boolean,
133
+ );
134
+ for (const query of queries) {
135
+ const response = await lookup(
136
+ base,
137
+ `-/v1/search?text=${encodeURIComponent(query)}&size=250&popularity=1&quality=0&maintenance=0`,
138
+ options,
139
+ );
140
+ if (!response.ok) {
141
+ throw new Error(
142
+ `npm similarity search failed (${response.status}); no approval requested`,
143
+ );
144
+ }
145
+ const document = await response.json();
146
+ for (const item of (document.objects ?? []).slice(0, 250)) {
147
+ const candidate = item.package?.name;
148
+ if (candidate && similarName(name, candidate)) {
149
+ throw await namingError(name, candidate, options);
150
+ }
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,102 @@
1
+ import { npmPrefillScript } from "./browser.mjs";
2
+
3
+ /** Verify the server-rendered publisher row, and surface form validation errors. */
4
+ export function publisherConfirmationScript(config, pending = true) {
5
+ return `(() => {
6
+ const config = ${JSON.stringify(config)};
7
+ const pending = ${JSON.stringify(pending)};
8
+ const errors = Array.from(document.querySelectorAll('#pending-github-publisher-form .form-errors, #pending-github-publisher-form .form-group__error, #github-publisher-form .form-errors, #github-publisher-form .form-group__error, .error-message, .notification-bar--danger')).map(node => node.textContent.trim()).filter(Boolean);
9
+ const rows = Array.from(document.querySelectorAll('tr, .publisher, .card'));
10
+ const confirmed = rows.some(row => {
11
+ const repository = Array.from(row.querySelectorAll('a[href]')).some(link => {
12
+ try { const url = new URL(link.href); return url.hostname === 'github.com' && (url.pathname === '/' + config.organization + '/' + config.repository || url.pathname.startsWith('/' + config.organization + '/' + config.repository + '/')); } catch { return false; }
13
+ });
14
+ return repository && row.querySelector('td')?.textContent.trim() === (pending ? config.project : 'GitHub')
15
+ && [config.workflow, config.environment].filter(Boolean).every(value => row.innerText.split(/\\s+/).includes(value));
16
+ });
17
+ return { confirmed, error: errors.join('; ') };
18
+ })()`;
19
+ }
20
+
21
+ /** Wait through sign-in, fill and verify a trusted-publisher form. */
22
+ export async function fillPublisherForm(
23
+ session,
24
+ pending = session.plan.mode !== "attach",
25
+ ) {
26
+ const page = session.automation;
27
+ const script = npmPrefillScript(session.plan.trusted_publisher, false);
28
+ const deadline = Date.now() + (session.options.waitTimeoutMs ?? 10 * 60_000);
29
+ console.log(
30
+ " Finish sign-in and 2FA in the browser; waiting for the trusted-publisher form.",
31
+ );
32
+ for (;;) {
33
+ const result = await page.evaluate(script).catch(() => ({}));
34
+ const required = [
35
+ "organization",
36
+ "repository",
37
+ "workflow",
38
+ ...(session.plan.registry === "pypi" && pending ? ["project"] : []),
39
+ ];
40
+ if (required.every((field) => result.filled?.includes(field))) {
41
+ break;
42
+ }
43
+ if (Date.now() >= deadline) {
44
+ throw new Error(
45
+ "timed out waiting for sign-in and the trusted-publisher form; nothing submitted",
46
+ );
47
+ }
48
+ await new Promise((resolve) =>
49
+ setTimeout(resolve, session.options.pollIntervalMs ?? 500),
50
+ );
51
+ }
52
+ const submit =
53
+ session.plan.registry === "pypi" ||
54
+ session.options.yes ||
55
+ /^(?:y|yes)$/i.test(
56
+ await session.prompt(
57
+ "Submit this trusted-publisher configuration? [y/N] ",
58
+ ),
59
+ );
60
+ if (!submit) {
61
+ throw new Error(
62
+ "trusted-publisher submission declined; release not started",
63
+ );
64
+ }
65
+ if (submit) {
66
+ const submission = await page.evaluate(
67
+ npmPrefillScript(session.plan.trusted_publisher, true),
68
+ );
69
+ if (!submission.submitted) {
70
+ throw new Error(
71
+ "the form was not submitted; review the visible browser and submit manually",
72
+ );
73
+ }
74
+ if (session.plan.registry === "pypi") {
75
+ for (;;) {
76
+ const state = await page
77
+ .evaluate(
78
+ publisherConfirmationScript(
79
+ session.plan.trusted_publisher,
80
+ pending,
81
+ ),
82
+ )
83
+ .catch(() => ({}));
84
+ if (state.error) {
85
+ throw new Error(`PyPI refused the pending publisher: ${state.error}`);
86
+ }
87
+ if (state.confirmed) {
88
+ session.conditions.delete("trust-missing");
89
+ break;
90
+ }
91
+ if (Date.now() >= deadline) {
92
+ throw new Error(
93
+ "timed out verifying the PyPI pending publisher; release not started",
94
+ );
95
+ }
96
+ await new Promise((resolve) =>
97
+ setTimeout(resolve, session.options.pollIntervalMs ?? 500),
98
+ );
99
+ }
100
+ }
101
+ }
102
+ }
package/src/python.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { readdir, mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { exec } from "command-stream";
4
5
 
@@ -136,7 +137,7 @@ export function applyPython(plan, tools) {
136
137
  detected: selected
137
138
  ? `${selected.program} (${selected.version})`
138
139
  : "no compatible interpreter found",
139
- required: `Install Python satisfying ${requirement || "the project requirements"} and its build module (python -m pip install build)`,
140
+ required: `Install Python satisfying ${requirement || "the project requirements"} with venv support; the tool installs build in a temporary environment`,
140
141
  ok: Boolean(selected),
141
142
  };
142
143
  plan.prerequisites = [
@@ -147,3 +148,38 @@ export function applyPython(plan, tools) {
147
148
  build.command = { program: selected.program, args: ["-m", "build"] };
148
149
  }
149
150
  }
151
+
152
+ /** Provision the build frontend without modifying the selected interpreter. */
153
+ export async function buildPythonDistribution(program, cwd, options = {}) {
154
+ const directory = await mkdtemp(path.join(os.tmpdir(), "prm-python-build-"));
155
+ const executable = path.join(
156
+ directory,
157
+ process.platform === "win32" ? "Scripts/python.exe" : "bin/python",
158
+ );
159
+ const run =
160
+ options.run ??
161
+ ((file, args) =>
162
+ exec(file, args, { cwd, capture: true, mirror: true, stdin: "ignore" }));
163
+ try {
164
+ for (const [file, args] of [
165
+ [program, ["-m", "venv", directory]],
166
+ [
167
+ executable,
168
+ ["-m", "pip", "install", "--disable-pip-version-check", "build"],
169
+ ],
170
+ [executable, ["-m", "build"]],
171
+ ]) {
172
+ if (options.verbose) {
173
+ console.error(`+ ${file} ${args.join(" ")}`);
174
+ }
175
+ const result = await run(file, args);
176
+ if (result.code !== 0) {
177
+ throw new Error(
178
+ `Python build preparation or build failed (${file}, status ${result.code}); ensure the selected Python supports venv and pip`,
179
+ );
180
+ }
181
+ }
182
+ } finally {
183
+ await rm(directory, { recursive: true, force: true });
184
+ }
185
+ }
@@ -140,6 +140,7 @@ export async function executePlans(plans, options, summary = true) {
140
140
  const deferred = new Map();
141
141
  const outcomes = [];
142
142
  let failure;
143
+ let preserveNpmSession = false;
143
144
  try {
144
145
  for (const plan of plans) {
145
146
  const session = new SetupSession(plan, {
@@ -170,6 +171,7 @@ export async function executePlans(plans, options, summary = true) {
170
171
  });
171
172
  failure ??= error;
172
173
  } finally {
174
+ preserveNpmSession ||= session.conditions.has("npm-name-refused");
173
175
  const steps = await session.cleanup({
174
176
  deferAuth: plans.length > 1,
175
177
  closeBrowser: false,
@@ -185,6 +187,12 @@ export async function executePlans(plans, options, summary = true) {
185
187
  }
186
188
  } finally {
187
189
  for (const { session, step } of deferred.values()) {
190
+ if (preserveNpmSession && step.id === "sign-out") {
191
+ console.log(
192
+ "Keeping the npm session after a naming refusal; rename the package and retry.",
193
+ );
194
+ continue;
195
+ }
188
196
  session.automation = automation;
189
197
  try {
190
198
  await session.runStep(step);
package/src/setup.mjs CHANGED
@@ -19,15 +19,12 @@ import {
19
19
  } from "./approvals.mjs";
20
20
  import { connectAutomation } from "./automation.mjs";
21
21
  import { runCratesApiStep } from "./crates-api.mjs";
22
- import {
23
- detachedRunner,
24
- npmPrefillScript,
25
- openInUserBrowser,
26
- } from "./browser.mjs";
22
+ import { detachedRunner, openInUserBrowser } from "./browser.mjs";
27
23
  import { detectDefaultBrowser, openWithCommand } from "./default-browser.mjs";
28
24
  import { CLEANUP_CONDITIONS } from "./flows.mjs";
29
25
  import { setupCredential } from "./credential-setup.mjs";
30
26
  import { checkNamePolicy, policyRefusal } from "./npm-policy.mjs";
27
+ import { namingError } from "./npm-similarity.mjs";
31
28
  import { pagesSettingsUrl, pagesState } from "./pages.mjs";
32
29
  import { packageDirectory } from "./plan.mjs";
33
30
  import { twoFactorMode } from "./prerequisites.mjs";
@@ -35,6 +32,7 @@ import { getJson, probePackage } from "./registry-state.mjs";
35
32
  import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
36
33
  import { PUBLISH_REJECTED, selectReleaseRun } from "./release-runs.mjs";
37
34
  import { runRepositoryRepair } from "./repository-repair.mjs";
35
+ import { buildPythonDistribution } from "./python.mjs";
38
36
  import {
39
37
  publisherIdentities,
40
38
  publisherMatches,
@@ -105,16 +103,16 @@ export class SetupSession {
105
103
  ["sign-out", "crates-sign-out"].includes(step.id) &&
106
104
  this.conditions.has(step.when)
107
105
  ) {
108
- if (step.id === "sign-out" && this.options.keepSession) {
106
+ if (step.id === "sign-out" && this.keepsSession(step)) {
109
107
  console.log(
110
- "Keeping the npm session (--keep-session); run npm logout to revoke it.",
108
+ "Keeping the npm session for reuse; run npm logout to revoke it.",
111
109
  );
112
110
  } else {
113
111
  deferred.push(step);
114
112
  }
115
113
  } else if (step.id === "sign-out" && this.keepsSession(step)) {
116
114
  console.log(
117
- "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.",
115
+ "Keeping the npm session for reuse: its token stays in npm's user configuration until you run npm logout, and the next run reuses it while npm whoami succeeds.",
118
116
  );
119
117
  } else if (this.conditions.has(step.when) && step.kind === "api") {
120
118
  this.log(`==> ${step.title}`);
@@ -148,7 +146,8 @@ export class SetupSession {
148
146
 
149
147
  keepsSession(step) {
150
148
  return (
151
- Boolean(this.options.keepSession) &&
149
+ (Boolean(this.options.keepSession) ||
150
+ this.conditions.has("npm-name-refused")) &&
152
151
  this.conditions.has(step.when) &&
153
152
  step.command?.program === "npm"
154
153
  );
@@ -168,8 +167,19 @@ export class SetupSession {
168
167
  }
169
168
  switch (step.kind) {
170
169
  case "check":
170
+ if (step.id === "build-package" && this.plan.registry === "pypi") {
171
+ return buildPythonDistribution(
172
+ step.command.program,
173
+ this.cwd(step),
174
+ this.options,
175
+ );
176
+ }
171
177
  if (step.id === "check-name-policy") {
172
- return checkNamePolicy(this.plan.package.name, this.options);
178
+ return checkNamePolicy(this.plan.package.name, {
179
+ ...this.options,
180
+ npmScope:
181
+ this.options.npmScope ?? this.plan.repository?.github_owner,
182
+ });
173
183
  }
174
184
  if (step.id === "check-registry") {
175
185
  return this.checkRegistry();
@@ -508,8 +518,19 @@ export class SetupSession {
508
518
  step.id === "first-publish" &&
509
519
  policyRefusal(String(result.stderr) + String(result.stdout))
510
520
  ) {
511
- throw new Error(
512
- "npm refused this name under its policy; no retry. Choose @owner/name or a longer descriptive name.",
521
+ this.conditions.add("npm-name-refused");
522
+ const output = String(result.stderr) + String(result.stdout);
523
+ const suggested = output.match(/@[a-z0-9._-]+\/[a-z0-9._-]+/g) ?? [];
524
+ throw await namingError(
525
+ this.plan.package.name,
526
+ output.match(/similar to existing package\s+([^\s;]+)/i)?.[1] ??
527
+ "another package",
528
+ {
529
+ ...this.options,
530
+ npmScope:
531
+ this.options.npmScope ?? this.plan.repository?.github_owner,
532
+ nameAlternatives: suggested,
533
+ },
513
534
  );
514
535
  }
515
536
  if (step.id === "attach-trusted-publisher") {
@@ -624,7 +645,8 @@ export class SetupSession {
624
645
  const fill = PREFILLED_FORMS.has(step.id) && this.plan.trusted_publisher;
625
646
  await this.open(step.url, Boolean(fill));
626
647
  if (fill && this.automation) {
627
- await this.prefill();
648
+ await this.prefill(step.id === "create-pending-publisher");
649
+ return;
628
650
  }
629
651
  await this.prompt("Finish this step in the browser, then press Enter...");
630
652
  if (step.id === "create-pending-publisher") {
@@ -632,33 +654,9 @@ export class SetupSession {
632
654
  }
633
655
  }
634
656
 
635
- async prefill() {
636
- await this.prompt("Press Enter when the form is visible...");
637
- const page = this.automation;
638
- const script = npmPrefillScript(this.plan.trusted_publisher, false);
639
- let result = await page.evaluate(script);
640
- if (result.filled?.length === 0) {
641
- await new Promise((resolve) => setTimeout(resolve, 500));
642
- result = await page.evaluate(script);
643
- }
644
- console.log(`Prefill result: ${JSON.stringify(result)}`);
645
- const submit =
646
- this.options.yes ||
647
- /^(?:y|yes)$/i.test(
648
- await this.prompt(
649
- "Submit this trusted-publisher configuration? [y/N] ",
650
- ),
651
- );
652
- if (submit) {
653
- const submission = await page.evaluate(
654
- npmPrefillScript(this.plan.trusted_publisher, true),
655
- );
656
- if (!submission.submitted) {
657
- throw new Error(
658
- "the form was not submitted; review the visible browser and submit manually",
659
- );
660
- }
661
- }
657
+ async prefill(pending = this.plan.mode !== "attach") {
658
+ const { fillPublisherForm } = await import("./publisher-form.mjs");
659
+ await fillPublisherForm(this, pending);
662
660
  }
663
661
 
664
662
  async open(url, automate = false) {
@@ -698,6 +696,7 @@ export class SetupSession {
698
696
  browser: await launchBrowser(browser, domains, {
699
697
  deferred: this.plan.registry === "crates-io",
700
698
  verbose: quiet ? false : this.options.verbose,
699
+ ...this.options.importDiscovery,
701
700
  }),
702
701
  profile: this.options.browserProfile,
703
702
  verbose: quiet ? false : this.options.verbose,
@@ -798,12 +797,12 @@ export class SetupSession {
798
797
  return npm ? withFreshLinks(run) : run();
799
798
  }
800
799
 
801
- openLink(url, kind) {
800
+ async openLink(url, kind) {
802
801
  console.log(approvalDeadline(kind));
803
802
  if (kind === "approve") {
804
803
  console.log(TWO_FACTOR_HINT);
805
804
  }
806
- this.open(url).catch((error) =>
805
+ await this.open(url, !this.options.openWith).catch((error) =>
807
806
  console.error(`warning: could not open ${url}: ${error.message}`),
808
807
  );
809
808
  }
@@ -1,9 +1,13 @@
1
1
  // Reusing a registry sign-in from an installed browser (#26): the automated
2
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.
3
+ // does not hold, the tool offers registry-domain cookies, then supported
4
+ // saved logins when no cookies exist. Credential values are never logged;
5
+ // discovery counts them through Browser Commander.
6
6
  import * as commander from "browser-commander";
7
+ import { mkdtemp, rm } from "node:fs/promises";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { detachedRunner } from "./browser.mjs";
7
11
 
8
12
  import { catalogueName } from "./browser-catalogue.mjs";
9
13
  import { detectDefaultBrowser } from "./default-browser.mjs";
@@ -50,7 +54,7 @@ export function sourceId(displayName) {
50
54
 
51
55
  /**
52
56
  * The migration Browser Commander runs: the whole profile for `full`, or
53
- * only the cookies of `domains`.
57
+ * registry-domain cookies, with usable saved logins for the automatic fallback.
54
58
  */
55
59
  export function migrationFor(source, scope, domains) {
56
60
  const migration = { browser: source.browser };
@@ -58,7 +62,9 @@ export function migrationFor(source, scope, domains) {
58
62
  migration.profile = source.profile;
59
63
  }
60
64
  if (scope === "domains" && domains.length > 0) {
61
- migration.include = ["cookies"];
65
+ migration.include = source.savedLogins
66
+ ? ["cookies", "passwords", "passkeys"]
67
+ : ["cookies"];
62
68
  migration.domains = domains;
63
69
  }
64
70
  return migration;
@@ -77,28 +83,51 @@ export async function findSignInSources(domains, options = {}) {
77
83
  (left, right) => Number(right === preferred) - Number(left === preferred),
78
84
  );
79
85
  const found = [];
86
+ let accessReported = false;
87
+ const report = async (browser, error) => {
88
+ if (
89
+ browser === "safari" &&
90
+ (options.platform ?? process.platform) === "darwin" &&
91
+ /EPERM|EACCES|operation not permitted|permission denied/i.test(
92
+ `${error.code} ${error.message}`,
93
+ ) &&
94
+ !accessReported
95
+ ) {
96
+ accessReported = true;
97
+ (options.warn ?? console.error)(
98
+ "Safari sign-ins are unreadable: enable Full Disk Access for your terminal app in Privacy & Security, then restart the terminal app and retry.",
99
+ );
100
+ await (
101
+ options.openSettings ??
102
+ (() =>
103
+ detachedRunner()("open", [
104
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles",
105
+ ]))
106
+ )().catch(() => {});
107
+ } else if (options.verbose) {
108
+ console.error(`could not read ${browser} sign-in data: ${error.message}`);
109
+ }
110
+ };
80
111
  for (const browser of browsers) {
81
- const profiles = await list({ browser }).catch((error) => {
82
- if (options.verbose) {
83
- console.error(`could not list ${browser} profiles: ${error.message}`);
84
- }
112
+ const profiles = await list({ browser }).catch(async (error) => {
113
+ await report(browser, error);
85
114
  return [];
86
115
  });
87
116
  for (const profile of profiles) {
117
+ if (profile.error) {
118
+ await report(browser, new Error(profile.error));
119
+ continue;
120
+ }
88
121
  const matched = [];
89
122
  for (const domain of domains) {
90
123
  const cookies = await read({
91
124
  browser,
92
- profile: profile.path,
125
+ profile: profile.name,
93
126
  domainFilter: domain,
94
127
  ignoreDecryptionErrors: true,
95
128
  cache: false,
96
- }).catch((error) => {
97
- if (options.verbose) {
98
- console.error(
99
- `could not read ${browser} cookies for ${domain}: ${error.message}`,
100
- );
101
- }
129
+ }).catch(async (error) => {
130
+ await report(browser, error);
102
131
  return [];
103
132
  });
104
133
  if (cookies.length > 0) {
@@ -109,7 +138,7 @@ export async function findSignInSources(domains, options = {}) {
109
138
  const name = profile.displayName ?? profile.name;
110
139
  found.push({
111
140
  browser,
112
- profile: profile.path,
141
+ profile: profile.name,
113
142
  label:
114
143
  profiles.length > 1
115
144
  ? `${sourceName(browser)} (${name})`
@@ -122,6 +151,70 @@ export async function findSignInSources(domains, options = {}) {
122
151
  return found;
123
152
  }
124
153
 
154
+ /** Count usable saved logins through upstream migration into a disposable profile. */
155
+ export async function findSavedLoginSources(domains, options = {}) {
156
+ if (
157
+ commander.findBrowserSource(options.targetBrowser ?? "chrome")
158
+ ?.controlProtocol === "bidi"
159
+ ) {
160
+ (options.warn ?? console.error)(
161
+ "Saved-password migration into Firefox is unavailable in Browser Commander; use --browser-channel chrome for saved-login autofill, or sign in in Firefox.",
162
+ );
163
+ return [];
164
+ }
165
+ const list = options.listProfiles ?? commander.listBrowserProfiles;
166
+ const migrate = options.migrateProfile ?? commander.migrateProfile;
167
+ const browsers = (options.sources ?? importSources()).toSorted(
168
+ (a, b) =>
169
+ Number(b === options.defaultSource) - Number(a === options.defaultSource),
170
+ );
171
+ const found = [];
172
+ const installed = [];
173
+ for (const browser of browsers) {
174
+ const profiles = await list({ browser }).catch(() => []);
175
+ if (profiles.length) {
176
+ installed.push(sourceName(browser));
177
+ }
178
+ for (const profile of profiles) {
179
+ const directory = await mkdtemp(
180
+ path.join(os.tmpdir(), "prm-login-probe-"),
181
+ );
182
+ try {
183
+ const report = await migrate({
184
+ from: { browser, profile: profile.name },
185
+ to: directory,
186
+ include: ["passwords", "passkeys"],
187
+ domains,
188
+ targetBrowser: options.targetBrowser ?? "chrome",
189
+ });
190
+ if (report.migrated.passwords > 0 || report.migrated.passkeys > 0) {
191
+ found.push({
192
+ browser,
193
+ profile: profile.name,
194
+ label: sourceName(browser),
195
+ domains,
196
+ savedLogins: true,
197
+ });
198
+ }
199
+ } catch {
200
+ if (options.verbose) {
201
+ console.error(
202
+ `saved-login import unavailable from ${sourceName(browser)}`,
203
+ );
204
+ }
205
+ } finally {
206
+ await rm(directory, { recursive: true, force: true });
207
+ }
208
+ }
209
+ }
210
+ if (!found.length) {
211
+ (options.warn ?? console.error)(
212
+ `No usable saved password for ${domains.join(", ")} in ${installed.join(", ") || "installed browsers"}. Safari saved passwords require an export; passkeys cannot be exported by the installed Browser Commander. Sign in in the browser.`,
213
+ );
214
+ }
215
+ return found;
216
+ }
217
+
125
218
  /** The source id of the system default browser, when it can be imported. */
126
219
  export async function defaultSource(options = {}) {
127
220
  const name =
@@ -180,11 +273,21 @@ export async function resolveImportChoice(choice, domains, options = {}) {
180
273
  }
181
274
  return { browser: preferred, profile: null };
182
275
  }
183
- const [found] = await findSignInSources(domains, {
276
+ const discovery = {
184
277
  ...options,
185
278
  defaultSource: preferred,
186
- });
187
- return found ? { browser: found.browser, profile: found.profile } : null;
279
+ };
280
+ const sources = await findSignInSources(domains, discovery);
281
+ const [found] = sources.length
282
+ ? sources
283
+ : await findSavedLoginSources(domains, discovery);
284
+ return found
285
+ ? {
286
+ browser: found.browser,
287
+ profile: found.profile,
288
+ ...(found.savedLogins ? { savedLogins: true } : {}),
289
+ }
290
+ : null;
188
291
  }
189
292
 
190
293
  /**
@@ -193,6 +296,7 @@ export async function resolveImportChoice(choice, domains, options = {}) {
193
296
  * once it knows the sign-in is missing (`deferred`).
194
297
  */
195
298
  export async function launchBrowser(browser, domains, options = {}) {
299
+ options = { targetBrowser: browser?.channel ?? "chrome", ...options };
196
300
  const choice = browser?.import?.browser;
197
301
  if (!IMPORT_CHOICES.includes(choice)) {
198
302
  return browser;
@@ -226,11 +330,12 @@ export async function offerSignInImport(session, domains) {
226
330
  return false;
227
331
  }
228
332
  const options = {
333
+ targetBrowser: browser.channel,
229
334
  verbose: session.options.verbose,
230
335
  ...session.options.importDiscovery,
231
336
  };
232
337
  const preferred = await defaultSource(options);
233
- const sources =
338
+ let sources =
234
339
  choice === "default"
235
340
  ? [preferred].filter(Boolean).map((id) => ({
236
341
  browser: id,
@@ -242,6 +347,12 @@ export async function offerSignInImport(session, domains) {
242
347
  ...options,
243
348
  defaultSource: preferred,
244
349
  });
350
+ if (!sources.length) {
351
+ sources = await findSavedLoginSources(domains, {
352
+ ...options,
353
+ defaultSource: preferred,
354
+ });
355
+ }
245
356
  const source = await chooseSignInSource(
246
357
  sources,
247
358
  domains,
@@ -253,7 +364,11 @@ export async function offerSignInImport(session, domains) {
253
364
  }
254
365
  await session.reconnect({
255
366
  ...browser,
256
- import: { browser: source.browser, profile: source.profile },
367
+ import: {
368
+ browser: source.browser,
369
+ profile: source.profile,
370
+ ...(source.savedLogins ? { savedLogins: true } : {}),
371
+ },
257
372
  });
258
373
  console.log(
259
374
  ` Imported the ${domains.join(" / ")} sign-in from ${source.label}.`,