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