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 +3 -3
- package/src/auth-urls.mjs +19 -3
- package/src/browser.mjs +14 -9
- package/src/cli-main.mjs +5 -5
- package/src/flows.mjs +1 -1
- package/src/npm-policy.mjs +4 -4
- package/src/npm-similarity.mjs +153 -0
- package/src/publisher-form.mjs +102 -0
- package/src/python.mjs +38 -2
- package/src/setup-batch.mjs +8 -0
- package/src/setup.mjs +41 -42
- package/src/sign-in-import.mjs +137 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.22.
|
|
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.
|
|
45
|
-
"command-stream": "^2.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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;
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
package/src/npm-policy.mjs
CHANGED
|
@@ -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
|
|
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"}
|
|
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
|
+
}
|
package/src/setup-batch.mjs
CHANGED
|
@@ -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.
|
|
106
|
+
if (step.id === "sign-out" && this.keepsSession(step)) {
|
|
109
107
|
console.log(
|
|
110
|
-
"Keeping the npm session
|
|
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
|
|
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,
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
}
|
package/src/sign-in-import.mjs
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
125
|
+
profile: profile.name,
|
|
93
126
|
domainFilter: domain,
|
|
94
127
|
ignoreDecryptionErrors: true,
|
|
95
128
|
cache: false,
|
|
96
|
-
}).catch((error) => {
|
|
97
|
-
|
|
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.
|
|
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
|
|
276
|
+
const discovery = {
|
|
184
277
|
...options,
|
|
185
278
|
defaultSource: preferred,
|
|
186
|
-
}
|
|
187
|
-
|
|
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
|
-
|
|
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: {
|
|
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}.`,
|