package-registry-manager 0.22.10 → 0.23.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/README.md CHANGED
@@ -35,6 +35,21 @@ one browser, groups npm approvals, and uses CI evidence to preserve healthy toke
35
35
  or verify replacements before revocation. `--secret-name '{REGISTRY}_TOKEN_{REPO}'`
36
36
  selects custom credential names.
37
37
 
38
+ For legacy crates.io consumers, use `setup --org ORG --registry crates-io
39
+ --rotate-token CARGO_TOKEN --execute --browser-import auto`. Rotation preserves
40
+ scopes and GitHub visibility, stores an expiry companion secret, verifies every
41
+ consumer in CI before revocation, and lists migration targets. Ordinary crates.io
42
+ setup uses trusted publishing. `inspect --org ORG --token-expiry-days 7` opens the
43
+ private browser token inventory for advance expiry warnings. See the
44
+ [rotation configuration](../README.md#configuration) for matching and failure behavior.
45
+
46
+ `setup --org ORG --registry crates-io --migrate --execute --browser-import auto`
47
+ proposes a reviewed conversion of existing jobs and reusable callers. After merging,
48
+ rerun it to register trust, and rerun after a real OIDC release to remove verified
49
+ legacy secrets. Shared organization secrets remain while other consumers exist.
50
+ The same flow supports `--registry npm` and `--registry pypi`; see the
51
+ [migration workflow](../README.md#configuration) for supported layouts and safeguards.
52
+
38
53
  Account scans print progress to stderr (`[37/169] owner/repository`) and stream
39
54
  text inspection results as each repository finishes. JSON inspection remains one
40
55
  report on stdout. Repository, manifest, registry lookup, and release-log failures are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.10",
3
+ "version": "0.23.0",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/29808e088ac8fd8374361920bbebab07a4d63ac6",
44
- "browser-commander": "^0.31.0",
45
- "command-stream": "^2.1.0",
44
+ "browser-commander": "^0.31.2",
45
+ "command-stream": "^2.4.0",
46
46
  "playwright": "^1.63.0",
47
47
  "selenium-webdriver": "^4.50.0"
48
48
  },
package/src/auth-urls.mjs CHANGED
@@ -111,6 +111,7 @@ export function runInteractive(command, options) {
111
111
  shell: false,
112
112
  });
113
113
  let legacyLogin = false;
114
+ let openings = Promise.resolve();
114
115
  const stop = options.stopOnLegacyLogin
115
116
  ? () => {
116
117
  legacyLogin = true;
@@ -119,7 +120,11 @@ export function runInteractive(command, options) {
119
120
  : undefined;
120
121
  const scan =
121
122
  options.onUrl || stop
122
- ? authUrlScanner(options.onUrl ?? (() => {}), stop)
123
+ ? authUrlScanner((url, kind) => {
124
+ openings = openings.then(() => options.onUrl?.(url, kind));
125
+ // Observe rejection until the command closes and propagates it.
126
+ openings.catch(() => {});
127
+ }, stop)
123
128
  : null;
124
129
  let stdout = "";
125
130
  child.stdout.setEncoding("utf8");
@@ -141,9 +146,20 @@ export function runInteractive(command, options) {
141
146
  });
142
147
  }
143
148
  child.on("error", reject);
144
- child.on("close", (code) => {
149
+ child.on("close", async (code) => {
145
150
  scan?.("\n");
146
- resolve({ code: code ?? 1, stdout, legacyLogin, approvalExpired });
151
+ try {
152
+ await openings;
153
+ resolve({
154
+ code: code ?? 1,
155
+ stdout,
156
+ stderr: errors,
157
+ legacyLogin,
158
+ approvalExpired,
159
+ });
160
+ } catch (error) {
161
+ reject(error);
162
+ }
147
163
  });
148
164
  });
149
165
  }
package/src/browser.mjs CHANGED
@@ -74,7 +74,13 @@ export function npmPrefillScript(prefill, submit = false) {
74
74
  const config = ${JSON.stringify(prefill)};
75
75
  const shouldSubmit = ${JSON.stringify(submit)};
76
76
  const normalized = value => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
77
- const controls = Array.from(document.querySelectorAll('input, select, textarea'));
77
+ const provider = Array.from(document.querySelectorAll('button, label, [role="radio"]'))
78
+ .find(element => normalized(element.textContent) === 'github');
79
+ if (provider) provider.click();
80
+ const githubForm = document.querySelector('#pending-github-publisher-form, #github-publisher-form');
81
+ const controls = Array.from((githubForm || document).querySelectorAll('input, select, textarea'))
82
+ .filter(control => !control.disabled && control.type !== 'hidden'
83
+ && (githubForm || control.getClientRects().length > 0));
78
84
  const setValue = (control, value) => {
79
85
  const prototype = control instanceof HTMLSelectElement
80
86
  ? HTMLSelectElement.prototype
@@ -88,7 +94,7 @@ export function npmPrefillScript(prefill, submit = false) {
88
94
  };
89
95
  const textFor = control => {
90
96
  const explicit = control.id
91
- ? document.querySelector(\`label[for="\${CSS.escape(control.id)}"]\`)?.textContent
97
+ ? (control.closest('form') || document).querySelector(\`label[for="\${CSS.escape(control.id)}"]\`)?.textContent
92
98
  : '';
93
99
  return normalized([explicit, control.getAttribute('aria-label'), control.name,
94
100
  control.placeholder, control.closest('label')?.textContent].filter(Boolean).join(' '));
@@ -103,19 +109,18 @@ export function npmPrefillScript(prefill, submit = false) {
103
109
  const filled = [];
104
110
  const used = new Set();
105
111
  for (const [labels, value] of values) {
106
- if (!value) continue;
112
+ if (!value && labels[0] !== 'environment') continue;
107
113
  const control = controls.find(candidate => !used.has(candidate)
108
114
  && labels.some(label => textFor(candidate).includes(label)));
109
115
  if (control) { setValue(control, value); used.add(control); filled.push(labels[0]); }
110
116
  }
111
- const provider = Array.from(document.querySelectorAll('button, label, [role="radio"]'))
112
- .find(element => normalized(element.textContent).includes('github'));
113
- if (provider && filled.length === 0) provider.click();
114
117
  let submitted = false;
115
- if (shouldSubmit && filled.length >= 3) {
116
- const button = Array.from(document.querySelectorAll('button, input[type="submit"]'))
118
+ const required = ['organization', 'repository', 'workflow', ...(githubForm?.id.startsWith('pending-') ? ['project'] : [])];
119
+ const form = githubForm || controls.find(control => /repository|repo/.test(textFor(control)))?.closest('form');
120
+ if (shouldSubmit && required.every(field => filled.includes(field))) {
121
+ const button = Array.from((form || document).querySelectorAll('button, input[type="submit"]'))
117
122
  .find(candidate => /add|save|configure|submit/.test(normalized(candidate.textContent || candidate.value))
118
- && !candidate.disabled);
123
+ && !candidate.disabled && !candidate.classList.contains('button--disabled'));
119
124
  if (button) { button.click(); submitted = true; }
120
125
  }
121
126
  return { filled, providerSelected: Boolean(provider), submitted };
@@ -48,13 +48,18 @@ export function completeRotation(state, repos, secret, id) {
48
48
  }
49
49
 
50
50
  /** CI drives rotation. Uncertain tests preserve every existing credential. */
51
- export async function cycleCredential(adapter) {
51
+ export async function cycleCredential(adapter, { force = false } = {}) {
52
52
  const health = await adapter.health();
53
- if (health.status === "ok") {
53
+ if (!force && health.status === "ok") {
54
54
  return { status: "ok", changed: false };
55
55
  }
56
56
  const previous = await adapter.metadata();
57
- if (health.status === "unknown" && previous && previous.present !== false) {
57
+ if (
58
+ !force &&
59
+ health.status === "unknown" &&
60
+ previous &&
61
+ previous.present !== false
62
+ ) {
58
63
  const tested = await adapter.test();
59
64
  if (tested.status === "ok") {
60
65
  return { status: "ok", changed: false };
package/src/cli-main.mjs CHANGED
@@ -35,6 +35,9 @@ Global options:
35
35
  --repository <path> Inspect only this repository (default: widest scope)
36
36
  --org <org> | --user <login> Scan repositories through gh-manager
37
37
  --secret-name <template> Secret name; supports {REGISTRY}, {REPO}, {ORG}
38
+ --rotate-token <secret> Explicitly replace a legacy crates.io token
39
+ --migrate Convert token jobs in PRs, then verify OIDC and clean up secrets
40
+ --token-expiry-days <N> Read browser token inventory in account scans
38
41
  --format <text|json> Output format (default: text)
39
42
  --offline Do not look up packages on registries
40
43
  --verbose Print command details and output, and
@@ -67,11 +70,11 @@ Setup options:
67
70
  through the crates.io API
68
71
  --keep-session Stay signed in after setup: npm keeps its
69
72
  session token in npm's user configuration
70
- until npm logout; crates.io keeps only the
71
- automated profile's browser session
73
+ until npm logout; the shared browser profile
74
+ keeps registry sessions across packages
72
75
  --browser <default|automated> Open sign-in and approval pages in your
73
- default browser, or in the automated
74
- profile too (default: default)
76
+ default browser, or in the automated profile;
77
+ npm reuses the automated profile
75
78
  --browser-channel <channel> Installed browser channel (default: chrome):
76
79
  ${launchChannels().join(", ")}
77
80
  --browser-executable <path> Installed browser executable to launch
@@ -83,7 +86,7 @@ Setup options:
83
86
  ${importSources().join(", ")}
84
87
  into the automated profile; default takes the
85
88
  system default browser, auto the first
86
- browser signed in to the registry
89
+ browser with registry cookies or saved logins
87
90
  --browser-import-scope <full|domains>
88
91
  Import the whole profile, or only the
89
92
  registry's sign-in cookies (default: full
@@ -111,6 +114,9 @@ export async function main(args = process.argv.slice(2)) {
111
114
  org: { type: "string" },
112
115
  user: { type: "string" },
113
116
  "secret-name": { type: "string" },
117
+ "rotate-token": { type: "string" },
118
+ migrate: { type: "boolean", default: false },
119
+ "token-expiry-days": { type: "string" },
114
120
  format: { type: "string", default: "text" },
115
121
  verbose: { type: "boolean", default: false },
116
122
  offline: { type: "boolean", default: false },
@@ -174,6 +180,47 @@ export async function main(args = process.argv.slice(2)) {
174
180
  }
175
181
  const repository = path.resolve(values.repository ?? ".");
176
182
  const command = positionals[0];
183
+ const rotateToken = values["rotate-token"];
184
+ if (
185
+ values.migrate &&
186
+ (command !== "setup" ||
187
+ rotateToken !== undefined ||
188
+ !values.registry?.length ||
189
+ (values.registry ?? []).some(
190
+ (registry) => !["crates-io", "npm", "pypi"].includes(registry),
191
+ ))
192
+ ) {
193
+ throw new Error(
194
+ "--migrate requires setup with crates-io, npm or pypi and cannot use --rotate-token",
195
+ );
196
+ }
197
+ if (
198
+ rotateToken !== undefined &&
199
+ (command !== "setup" ||
200
+ (values.registry ?? []).map(parseRegistry).join() !== "crates-io" ||
201
+ !/^[A-Z_][A-Z0-9_]{0,244}$/.test(rotateToken) ||
202
+ rotateToken.startsWith("GITHUB_"))
203
+ ) {
204
+ throw new Error(
205
+ "--rotate-token requires setup --registry crates-io and a valid secret name",
206
+ );
207
+ }
208
+ const tokenExpiryDays =
209
+ values["token-expiry-days"] === undefined
210
+ ? undefined
211
+ : Number(values["token-expiry-days"]);
212
+ if (
213
+ tokenExpiryDays !== undefined &&
214
+ (!/^\d+$/.test(values["token-expiry-days"]) ||
215
+ !Number.isInteger(tokenExpiryDays) ||
216
+ tokenExpiryDays < 0 ||
217
+ tokenExpiryDays > 365 ||
218
+ !(values.org || values.user))
219
+ ) {
220
+ throw new Error(
221
+ "--token-expiry-days requires an account scan and an integer between 0 and 365",
222
+ );
223
+ }
177
224
  if (values.org || values.user) {
178
225
  if (values.org && values.user) {
179
226
  throw new Error("--org and --user are mutually exclusive");
@@ -184,7 +231,10 @@ export async function main(args = process.argv.slice(2)) {
184
231
  if (!["inspect", "plan", "setup"].includes(command)) {
185
232
  throw new Error(`unknown command '${command}'`);
186
233
  }
187
- if (command === "setup" && (!values.all || values.package)) {
234
+ if (
235
+ command === "setup" &&
236
+ ((!values.all && !rotateToken && !values.migrate) || values.package)
237
+ ) {
188
238
  throw new Error("account setup requires --all without --package");
189
239
  }
190
240
  if (values["dry-run"] && values.execute) {
@@ -215,6 +265,9 @@ export async function main(args = process.argv.slice(2)) {
215
265
  yes: values.yes,
216
266
  noBrowser: values["no-browser"],
217
267
  secretName: values["secret-name"],
268
+ rotateToken,
269
+ migrate: values.migrate,
270
+ tokenExpiryDays,
218
271
  package: values.package,
219
272
  browser: values.browser,
220
273
  browserOptions,
@@ -228,6 +281,7 @@ export async function main(args = process.argv.slice(2)) {
228
281
  workflow: values.workflow,
229
282
  publisherEnvironment: values.environment,
230
283
  addPublishJob: values["add-publish-job"],
284
+ migrate: values.migrate,
231
285
  manual: values.manual,
232
286
  verifyRelease: values["verify-release"],
233
287
  },
@@ -269,6 +323,8 @@ export async function main(args = process.argv.slice(2)) {
269
323
  ),
270
324
  });
271
325
  const planOptions = {
326
+ rotateToken,
327
+ migrate: values.migrate,
272
328
  ref: values.ref,
273
329
  verifyRelease: values["verify-release"],
274
330
  manual: values.manual,
@@ -327,8 +383,12 @@ export async function main(args = process.argv.slice(2)) {
327
383
  openWith: nonEmpty(values["open-with"], "--open-with"),
328
384
  keepSession: values["keep-session"],
329
385
  secretName: values["secret-name"],
386
+ rotateToken,
387
+ migrate: values.migrate,
330
388
  quietBrowser: selected.some((plan) =>
331
- plan.steps.some((step) => step.id === "manage-registry-token"),
389
+ plan.steps.some((step) =>
390
+ ["manage-registry-token", "rotate-crates-token"].includes(step.id),
391
+ ),
332
392
  ),
333
393
  browser: values.browser,
334
394
  browserOptions,
@@ -104,15 +104,16 @@ export async function readSession(page) {
104
104
  * unverified one stops here with the settings link.
105
105
  */
106
106
  export async function signIn(page, options = {}) {
107
+ const log = options.log ?? console.log;
107
108
  await page.goto(CRATES_IO_URL);
108
109
  let session = await readSession(page);
109
110
  const signedInBefore = session.signedIn;
110
111
  if (!session.signedIn) {
111
- console.log(
112
+ log(
112
113
  " Not signed in to crates.io; finish the GitHub sign-in in the automated browser.",
113
114
  );
114
115
  if (!(await page.evaluate(LOGIN_CLICK_SCRIPT))) {
115
- console.log(' Click "Log in with GitHub" on crates.io.');
116
+ log(' Click "Log in with GitHub" on crates.io.');
116
117
  }
117
118
  const deadline = Date.now() + (options.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
118
119
  const interval = options.pollIntervalMs ?? 2_000;
@@ -124,9 +125,7 @@ export async function signIn(page, options = {}) {
124
125
  session = await readSession(page);
125
126
  }
126
127
  }
127
- console.log(
128
- ` Signed in to crates.io as ${session.login ?? "your account"}.`,
129
- );
128
+ log(` Signed in to crates.io as ${session.login ?? "your account"}.`);
130
129
  if (!session.emailVerified) {
131
130
  const sent = session.emailSent
132
131
  ? " A verification email was sent; follow its link,"
@@ -0,0 +1,265 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { githubServices } from "./github.mjs";
4
+ import {
5
+ cycleCredential,
6
+ secretName,
7
+ trackedTokenIds,
8
+ recordTokenId,
9
+ completeRotation,
10
+ } from "./ci-credential-cycle.mjs";
11
+ import { pageFetchScript, runCratesApiStep } from "./crates-api.mjs";
12
+ import {
13
+ listCratesTokens,
14
+ selectCratesToken,
15
+ replacementTokenRequest,
16
+ } from "./crates-tokens.mjs";
17
+ import { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
18
+
19
+ /** Explicit maintenance of existing legacy secrets; ordinary crates setup uses OIDC. */
20
+ export async function rotateCratesToken(session) {
21
+ const { options, plan } = session;
22
+ const secret = secretName(
23
+ options.rotateToken,
24
+ "crates-io",
25
+ `${plan.repository.github_owner}/${plan.repository.github_repository}`,
26
+ );
27
+ if (
28
+ options.noBrowser ||
29
+ options.browserOptions?.attach ||
30
+ options.browserOptions?.importScope === "full"
31
+ ) {
32
+ throw new Error(
33
+ "token rotation requires a dedicated profile with domain-scoped sign-in import",
34
+ );
35
+ }
36
+ const settings =
37
+ JSON.parse(
38
+ await readFile(
39
+ path.join(options.repository, ".package-registry-manager.json"),
40
+ "utf8",
41
+ ).catch((error) => {
42
+ if (error.code === "ENOENT") {
43
+ return "{}";
44
+ }
45
+ throw error;
46
+ }),
47
+ ).tokens?.["crates-io"] ?? {};
48
+ if (settings.level && !["repo", "org"].includes(settings.level)) {
49
+ throw new Error("token secret level must be repo or org");
50
+ }
51
+ const github = options.github ?? githubServices(options);
52
+ const owner = plan.repository.github_owner;
53
+ const slug = `${owner}/${plan.repository.github_repository}`;
54
+ if (!owner || !plan.repository.github_repository) {
55
+ throw new Error("token rotation requires GitHub repository coordinates");
56
+ }
57
+ const key = `${settings.level === "repo" ? slug : owner}:${secret}`;
58
+ if (options.rotatedSecrets?.has(key)) {
59
+ const outcome = options.rotatedSecrets.get(key);
60
+ if (!outcome) {
61
+ throw new Error(
62
+ "previous rotation could not be verified; credentials retained",
63
+ );
64
+ }
65
+ session.outcome = outcome;
66
+ return;
67
+ }
68
+ options.rotatedSecrets?.set(key, null);
69
+ const targets =
70
+ settings.level === "repo"
71
+ ? [slug]
72
+ : (options.secretRepositories?.[`crates-io:${secret}`] ?? [slug]);
73
+ const scope = settings.level === "repo" ? { repo: slug } : { org: owner };
74
+ let metadata;
75
+ try {
76
+ metadata = await github.secrets(scope).getMetadata(secret);
77
+ } catch (error) {
78
+ if (![403, 404, 422].includes(error.status)) {
79
+ throw error;
80
+ }
81
+ }
82
+ metadata ??= await github.secrets({ repo: slug }).getMetadata(secret);
83
+ if (!metadata) {
84
+ throw new Error(`existing secret ${secret} was not found`);
85
+ }
86
+ // Keep access unchanged and test consumers beyond the current plan selection too.
87
+ const granted =
88
+ metadata.selected_repositories?.map((repo) => repo.full_name) ?? [];
89
+ const consumers = [...new Set([...targets, ...granted])];
90
+ if (
91
+ scope.org &&
92
+ ["all", "private"].includes(metadata.visibility) &&
93
+ !options.accountScan
94
+ ) {
95
+ throw new Error(
96
+ "rotation of an all/private organization secret requires --org to discover every consumer",
97
+ );
98
+ }
99
+ if (options.accountScan && options.scanErrors) {
100
+ throw new Error("token rotation requires a complete account scan");
101
+ }
102
+ if (
103
+ !options.yes &&
104
+ !/^y(?:es)?$/i.test(
105
+ await session.prompt(
106
+ "Replace the legacy token, verify consumers in CI, then revoke the old token? [y/N] ",
107
+ ),
108
+ )
109
+ ) {
110
+ throw new Error("credential creation declined");
111
+ }
112
+ await runCratesApiStep(session, { id: "crates-sign-in" });
113
+ const page = await session.automatedPage(options.browserOptions, true);
114
+ const previous = selectCratesToken(
115
+ await listCratesTokens(page),
116
+ secret,
117
+ metadata,
118
+ settings.token_id,
119
+ );
120
+ const request = replacementTokenRequest(previous, settings.expiry_days ?? 30);
121
+ const stateFile =
122
+ options.browserProfile &&
123
+ path.join(path.dirname(options.browserProfile), "credential-ids.json");
124
+ const state = stateFile
125
+ ? JSON.parse(
126
+ await readFile(stateFile, "utf8").catch((error) => {
127
+ if (error.code === "ENOENT") {
128
+ return "{}";
129
+ }
130
+ throw error;
131
+ }),
132
+ )
133
+ : {};
134
+ const persist = async () => {
135
+ if (stateFile) {
136
+ await mkdir(path.dirname(stateFile), { recursive: true });
137
+ await writeFile(stateFile, JSON.stringify(state));
138
+ }
139
+ };
140
+ const previousId = String(previous.id);
141
+ const retained = Object.entries(state).some(
142
+ ([entry, ids]) =>
143
+ !consumers.some((repo) => entry === `${repo}:${secret}`) &&
144
+ ids.includes(previousId),
145
+ );
146
+ if (options.verbose) {
147
+ console.error(
148
+ `Rotate crates.io token ${previousId}; verify ${consumers.join(", ")}`,
149
+ );
150
+ }
151
+ const result = await cycleCredential(
152
+ {
153
+ health: async () => ({ status: "ok" }),
154
+ metadata: async () => ({
155
+ token_id: retained ? undefined : previousId,
156
+ token_ids: trackedTokenIds(state, consumers, secret),
157
+ }),
158
+ create: async () => {
159
+ const created = await page.evaluate(
160
+ pageFetchScript("PUT", "/api/v1/me/tokens", request),
161
+ );
162
+ const token = created.body?.api_token;
163
+ if (
164
+ created.status !== 200 ||
165
+ !token?.token ||
166
+ token.id === undefined ||
167
+ token.id === null
168
+ ) {
169
+ throw new Error(
170
+ `crates.io token creation failed (${created.status})`,
171
+ );
172
+ }
173
+ return {
174
+ id: String(token.id),
175
+ value: token.token,
176
+ expires_at: token.expired_at ?? request.api_token.expired_at,
177
+ };
178
+ },
179
+ ensure: async (credential) => {
180
+ const broad =
181
+ scope.org && ["all", "private"].includes(metadata.visibility);
182
+ const stored = broad
183
+ ? {
184
+ ...(await github.secrets(scope).set(secret, credential.value, {
185
+ visibility: metadata.visibility,
186
+ expiresAt: credential.expires_at,
187
+ })),
188
+ path: "organization",
189
+ }
190
+ : await github.secrets(scope).ensure(secret, {
191
+ ...(scope.org
192
+ ? { repos: consumers, visibility: "selected" }
193
+ : {}),
194
+ health: { status: "auth-failing" },
195
+ rotateBeforeMs: 0,
196
+ acquire: async () => ({
197
+ value: credential.value,
198
+ expiresAt: credential.expires_at,
199
+ }),
200
+ });
201
+ for (const repo of consumers) {
202
+ if (
203
+ stored.path === "organization" &&
204
+ (await github.secrets({ repo }).getMetadata(secret))
205
+ ) {
206
+ await github.secrets({ repo }).set(secret, credential.value, {
207
+ expiresAt: credential.expires_at,
208
+ });
209
+ }
210
+ }
211
+ if (stored.valueChanged === false) {
212
+ throw new Error(
213
+ "gh-manager did not store the replacement credential",
214
+ );
215
+ }
216
+ recordTokenId(state, consumers, secret, credential.id);
217
+ await persist();
218
+ return stored;
219
+ },
220
+ test: async () => {
221
+ for (const repo of consumers) {
222
+ const result = await github.health.test(secret, {
223
+ scope: { repo },
224
+ failurePatterns: AUTH_FAILURE_PATTERNS["crates-io"],
225
+ });
226
+ if (result.status !== "ok") {
227
+ return { status: "unknown" };
228
+ }
229
+ }
230
+ return { status: "ok" };
231
+ },
232
+ revoke: async (id) => {
233
+ const revoked = await page.evaluate(
234
+ pageFetchScript(
235
+ "DELETE",
236
+ `/api/v1/me/tokens/${encodeURIComponent(id)}`,
237
+ ),
238
+ );
239
+ if (![200, 204].includes(revoked.status)) {
240
+ throw new Error(
241
+ `crates.io token revocation failed (${revoked.status})`,
242
+ );
243
+ }
244
+ },
245
+ revoked: async (id) =>
246
+ !(await listCratesTokens(page)).some(
247
+ (token) => String(token.id) === id,
248
+ ),
249
+ },
250
+ { force: true },
251
+ );
252
+ completeRotation(state, consumers, secret, result.token_id);
253
+ await persist();
254
+ session.outcome = {
255
+ status: "configured",
256
+ secret,
257
+ secret_scope: result.path,
258
+ fallback_reason: result.fallbackReason,
259
+ token_id: result.token_id,
260
+ };
261
+ options.rotatedSecrets?.set(key, session.outcome);
262
+ console.log(
263
+ `Migrate these repositories to crates.io trusted publishing before deleting the legacy token secret: ${consumers.join(", ")}`,
264
+ );
265
+ }