package-registry-manager 0.22.4 → 0.22.6

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
@@ -10,12 +10,16 @@ browser, filling forms in a dedicated visible browser profile.
10
10
  npx package-registry-manager inspect --repository /path/to/repository
11
11
  npx package-registry-manager plan --repository /path/to/repository --format json
12
12
  npx package-registry-manager setup --repository /path/to/repository --registry npm
13
+ npx package-registry-manager setup --repository /path/to/repository --all --execute
13
14
  ```
14
15
 
15
16
  The package installs the command as both `package-registry-manager` and
16
17
  `package-registry-manager-js`; use the second name when the Rust command of the
17
18
  same name is also on your `PATH`.
18
19
 
19
- Setup is a dry run unless `--execute` is present, and it never publishes an
20
- artifact. See the repository README for all registry walkthroughs and safety
21
- details.
20
+ Setup is a dry run unless `--execute` is present. It can publish the first
21
+ version after confirmation; later versions publish through CI. Missing CI
22
+ publishing jobs are offered in a draft pull request, and PyPI builds select an
23
+ interpreter satisfying `requires-python`. See the repository README for the
24
+ registry walkthroughs and safety details.
25
+ See the [bootstrap, wrapper and credential guide](../docs/registry-setup.md) for branch/PR setup, dual npm names, registry policies and integration limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.4",
3
+ "version": "0.22.6",
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/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-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",
31
+ "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-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 && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.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 .",
@@ -40,8 +40,8 @@
40
40
  "audit": "node scripts/npm-audit.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "browser-commander": "^0.25.0",
44
- "command-stream": "^1.6.2",
43
+ "browser-commander": "^0.26.2",
44
+ "command-stream": "^2.0.0",
45
45
  "playwright": "^1.63.0",
46
46
  "selenium-webdriver": "^4.50.0"
47
47
  },
@@ -0,0 +1,76 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { spawnSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { inspectRepository } from "./discovery.mjs";
6
+
7
+ /** Translate a branch, PR number, or GitHub pull-request URL into a fetch ref. */
8
+ export function bootstrapRef(ref) {
9
+ const pull =
10
+ /^(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/)?(\d+)$/.exec(ref);
11
+ const result = pull ? `refs/pull/${pull[1]}/head` : ref;
12
+ if (
13
+ !/^[\w][\w./-]*$/.test(result) ||
14
+ result.includes("..") ||
15
+ result.includes("//") ||
16
+ result.endsWith("/") ||
17
+ result.toLowerCase().endsWith(".lock")
18
+ ) {
19
+ throw new Error("invalid bootstrap ref");
20
+ }
21
+ return result;
22
+ }
23
+
24
+ /** Inspect a pushed ref in a disposable worktree without changing the checkout. */
25
+ export async function inspectReference(repository, ref, options) {
26
+ const run = (args) => {
27
+ const result = spawnSync("git", args, {
28
+ cwd: repository,
29
+ encoding: "utf8",
30
+ });
31
+ if (result.status !== 0) {
32
+ throw new Error(`git failed while inspecting ref: ${result.stderr}`);
33
+ }
34
+ return result.stdout.trim();
35
+ };
36
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-ref-"));
37
+ const checkout = path.join(temporary, "checkout");
38
+ let created = false;
39
+ try {
40
+ run(["fetch", "origin", "HEAD"]);
41
+ const main = run(["rev-parse", "FETCH_HEAD"]);
42
+ run(["fetch", "origin", bootstrapRef(ref)]);
43
+ run(["worktree", "add", "--detach", checkout, "FETCH_HEAD"]);
44
+ created = true;
45
+ const inspection = await inspectRepository(checkout, options);
46
+ for (const item of inspection.packages.filter(
47
+ (item) => item.registry === "npm",
48
+ )) {
49
+ const result = spawnSync("git", ["show", `${main}:${item.manifest}`], {
50
+ cwd: repository,
51
+ encoding: "utf8",
52
+ });
53
+ let relationship = "manifest is absent from remote main";
54
+ if (result.status === 0) {
55
+ const data = JSON.parse(result.stdout);
56
+ relationship = `remote main is ${data.name}@${data.version}`;
57
+ }
58
+ item.warnings = [
59
+ ...(item.warnings ?? []),
60
+ `bootstrap ${item.name}@${item.version} from ${ref}; ${relationship}; the next main release must use an unpublished version`,
61
+ ];
62
+ }
63
+ inspection.repository.root = await import("node:fs/promises").then(
64
+ ({ realpath }) => realpath(repository),
65
+ );
66
+ return inspection;
67
+ } finally {
68
+ try {
69
+ if (created) {
70
+ run(["worktree", "remove", "--force", checkout]);
71
+ }
72
+ } finally {
73
+ await rm(temporary, { recursive: true, force: true });
74
+ }
75
+ }
76
+ }
@@ -11,7 +11,7 @@ export const browserSources = () => BROWSER_IDS.map(findBrowserSource);
11
11
  /** Catalogue channels with a launch control protocol. */
12
12
  export function launchChannels() {
13
13
  return browserSources()
14
- .filter((source) => source.controlProtocol)
14
+ .filter((source) => ["cdp", "bidi"].includes(source.controlProtocol))
15
15
  .flatMap((source) => [source.id, ...(source.aliases ?? [])]);
16
16
  }
17
17
 
@@ -122,11 +122,11 @@ export function installedDescription(options = {}) {
122
122
  /** Reject unavailable engines before creating an automated profile. */
123
123
  export function validateChannel(channel) {
124
124
  const known = findBrowserSource(channel);
125
- if (known?.controlProtocol) {
125
+ if (["cdp", "bidi"].includes(known?.controlProtocol)) {
126
126
  return channel;
127
127
  }
128
128
  const reason = known
129
- ? `${channel} has no launch control protocol in browser-commander's catalogue${known.family === "safari" ? " (Safari setup: https://github.com/link-foundation/browser-commander/issues/126)" : ""}`
129
+ ? `${channel} has no launch control protocol supported by this manager${known.family === "safari" ? " (Safari setup: https://github.com/link-foundation/browser-commander/issues/126)" : ""}`
130
130
  : `unknown --browser-channel '${channel}'`;
131
131
  throw new Error(
132
132
  `${reason}; choose from ${launchChannels().join(", ")}. ${installedDescription()}`,
package/src/cli.mjs CHANGED
@@ -9,12 +9,17 @@ import { parseArgs } from "node:util";
9
9
  import { launchChannels } from "./browser-catalogue.mjs";
10
10
  import { parseBrowserOptions } from "./browser-options.mjs";
11
11
  import { isDirectExecution } from "./direct-execution.mjs";
12
+ import { inspectReference } from "./bootstrap-reference.mjs";
12
13
  import { inspectRepository } from "./discovery.mjs";
13
14
  import { parseRegistry } from "./model.mjs";
14
15
  import { buildPlans } from "./plan.mjs";
15
16
  import { probeEnvironment, renderPrerequisites } from "./prerequisites.mjs";
16
17
  import { probeRegistryState } from "./registry-state.mjs";
17
- import { BROWSER_MODES, defaultBrowserProfile, executePlan } from "./setup.mjs";
18
+ import {
19
+ BROWSER_MODES,
20
+ defaultBrowserProfile,
21
+ executePlans,
22
+ } from "./setup.mjs";
18
23
  import { importSources } from "./sign-in-import.mjs";
19
24
 
20
25
  export { isDirectExecution };
@@ -25,6 +30,7 @@ Commands:
25
30
  inspect Discover supported package manifests
26
31
  plan [--registry <registry>] Print ordered setup plans
27
32
  setup --registry <registry> Bootstrap or attach trusted publishing
33
+ setup --all Set up every publishable package in one session
28
34
 
29
35
  Global options:
30
36
  -h, --help Print this help
@@ -41,7 +47,10 @@ Plan and setup options:
41
47
  --workflow <file> Trusted-publisher workflow file in
42
48
  .github/workflows, when detection finds
43
49
  several or the wrong one
50
+ --add-publish-job Propose missing publishing jobs in a draft PR;
51
+ --workflow chooses its target file
44
52
  --environment <name> GitHub environment of the trusted publisher
53
+ --ref <branch|pr> Bootstrap a pushed branch or PR head before merge
45
54
  --package <name> Plan only this package, or select it for
46
55
  setup when a registry has several
47
56
 
@@ -98,17 +107,20 @@ export async function main(args = process.argv.slice(2)) {
98
107
  allowPositionals: true,
99
108
  strict: true,
100
109
  options: {
110
+ ref: { type: "string" },
101
111
  repository: { type: "string", default: "." },
102
112
  format: { type: "string", default: "text" },
103
113
  verbose: { type: "boolean", default: false },
104
114
  offline: { type: "boolean", default: false },
105
115
  "verify-release": { type: "boolean", default: false },
106
116
  workflow: { type: "string" },
117
+ "add-publish-job": { type: "boolean", default: false },
107
118
  environment: { type: "string" },
108
119
  "dry-run": { type: "boolean", default: false },
109
120
  registry: { type: "string", multiple: true },
110
121
  package: { type: "string" },
111
122
  execute: { type: "boolean", default: false },
123
+ all: { type: "boolean", default: false },
112
124
  yes: { type: "boolean", default: false },
113
125
  "no-browser": { type: "boolean", default: false },
114
126
  "open-with": { type: "string" },
@@ -156,9 +168,11 @@ export async function main(args = process.argv.slice(2)) {
156
168
  });
157
169
 
158
170
  const repository = path.resolve(values.repository);
159
- const discovered = await inspectRepository(repository, {
160
- includeSkipped: values.verbose,
161
- });
171
+ const discovered = values.ref
172
+ ? await inspectReference(repository, values.ref, {
173
+ includeSkipped: values.verbose,
174
+ })
175
+ : await inspectRepository(repository, { includeSkipped: values.verbose });
162
176
  const inspection = values.offline
163
177
  ? discovered
164
178
  : await probeRegistryState(discovered, { verbose: values.verbose });
@@ -175,6 +189,12 @@ export async function main(args = process.argv.slice(2)) {
175
189
  const environment = await probeEnvironment({
176
190
  offline: values.offline,
177
191
  verbose: values.verbose,
192
+ python: inspection.packages.some(
193
+ (item) =>
194
+ item.registry === "pypi" &&
195
+ item.publishable &&
196
+ (registries.length === 0 || registries.includes("pypi")),
197
+ ),
178
198
  npm: inspection.packages.some(
179
199
  (item) =>
180
200
  item.registry === "npm" &&
@@ -183,9 +203,15 @@ export async function main(args = process.argv.slice(2)) {
183
203
  ),
184
204
  });
185
205
  const planOptions = {
206
+ ref: values.ref,
186
207
  verifyRelease: values["verify-release"],
187
208
  manual: values.manual,
188
- workflow: await workflowOverride(repository, values.workflow),
209
+ workflow: await workflowOverride(
210
+ repository,
211
+ values.workflow,
212
+ values["add-publish-job"],
213
+ ),
214
+ addPublishJob: values["add-publish-job"],
189
215
  publisherEnvironment: nonEmpty(values.environment, "--environment"),
190
216
  environment,
191
217
  browser: browserSummary(command, values, browserOptions),
@@ -198,7 +224,10 @@ export async function main(args = process.argv.slice(2)) {
198
224
  outputPlans(plans, values.format);
199
225
  return;
200
226
  }
201
- if (registries.length !== 1) {
227
+ if (values.all && values.package) {
228
+ throw new Error("--all and --package are mutually exclusive");
229
+ }
230
+ if (!values.all && registries.length !== 1) {
202
231
  throw new Error("setup requires exactly one --registry <registry>");
203
232
  }
204
233
  if (values["dry-run"] && values.execute) {
@@ -215,27 +244,36 @@ export async function main(args = process.argv.slice(2)) {
215
244
  }
216
245
 
217
246
  const plans = buildPlans(inspection, registries, planOptions);
218
- const plan = selectPlan(plans, values.package);
219
- outputPlans([plan], values.format);
220
- await executePlan(plan, {
221
- repository,
222
- execute: values.execute,
223
- yes: values.yes,
224
- noBrowser: values["no-browser"],
225
- openWith: nonEmpty(values["open-with"], "--open-with"),
226
- keepSession: values["keep-session"],
227
- browser: values.browser,
228
- browserOptions,
229
- browserProfile: path.resolve(
230
- values["browser-profile"] ??
231
- defaultBrowserProfile({ channel: browserOptions.channel }),
232
- ),
233
- verbose: values.verbose,
234
- verifyRelease: values["verify-release"],
235
- });
247
+ const selected = values.all
248
+ ? plans.filter((plan) => plan.package.publishable)
249
+ : [selectPlan(plans, values.package)];
250
+ outputPlans(selected, values.format);
251
+ await executePlans(
252
+ selected,
253
+ {
254
+ inspection,
255
+ workflow: planOptions.workflow,
256
+ publisherEnvironment: planOptions.publisherEnvironment,
257
+ repository,
258
+ execute: values.execute,
259
+ yes: values.yes,
260
+ noBrowser: values["no-browser"],
261
+ openWith: nonEmpty(values["open-with"], "--open-with"),
262
+ keepSession: values["keep-session"],
263
+ browser: values.browser,
264
+ browserOptions,
265
+ browserProfile: path.resolve(
266
+ values["browser-profile"] ??
267
+ defaultBrowserProfile({ channel: browserOptions.channel }),
268
+ ),
269
+ verbose: values.verbose,
270
+ verifyRelease: values["verify-release"],
271
+ },
272
+ values.all,
273
+ );
236
274
  }
237
275
 
238
- async function workflowOverride(repository, workflow) {
276
+ async function workflowOverride(repository, workflow, adding = false) {
239
277
  if (workflow === undefined) {
240
278
  return undefined;
241
279
  }
@@ -249,7 +287,7 @@ async function workflowOverride(repository, workflow) {
249
287
  (metadata) => metadata.isFile(),
250
288
  () => false,
251
289
  );
252
- if (!found) {
290
+ if (!found && !adding) {
253
291
  throw new Error(`--workflow: .github/workflows/${workflow} does not exist`);
254
292
  }
255
293
  return workflow;
@@ -382,6 +420,9 @@ function outputPlans(plans, format) {
382
420
  for (const plan of plans) {
383
421
  const mode = plan.mode ? ` (${plan.mode})` : "";
384
422
  process.stdout.write(`${plan.registry}: ${plan.package.name}${mode}\n`);
423
+ process.stdout.write(
424
+ ` credentials: ${plan.credential_policy.description}\n`,
425
+ );
385
426
  if (plan.mode === "complete") {
386
427
  process.stdout.write(" trusted publishing is already in use\n");
387
428
  }
@@ -1,3 +1,5 @@
1
+ import { tokenSecrets } from "./publishers.mjs";
2
+ import { stripComments } from "./source-code.mjs";
1
3
  import { grantsPackagesWrite, publishingWorkflow } from "./workflows.mjs";
2
4
 
3
5
  export const CONTAINER_FILES = new Set(["Dockerfile", "Containerfile"]);
@@ -27,7 +29,8 @@ export function containerPackages(dockerfiles, workflows, coordinates) {
27
29
  continue;
28
30
  }
29
31
  const name =
30
- literalImage(workflow.contents, host) ?? defaultImage(coordinates);
32
+ literalImage(stripComments(workflow.contents), host) ??
33
+ defaultImage(coordinates);
31
34
  const item = {
32
35
  registry,
33
36
  name: name ?? "unknown-image",
@@ -36,6 +39,10 @@ export function containerPackages(dockerfiles, workflows, coordinates) {
36
39
  publishable: Boolean(name),
37
40
  workflow: workflow.name,
38
41
  };
42
+ const secrets = tokenSecrets(workflows, registry);
43
+ if (secrets.length) {
44
+ item.token_secrets = [...new Set(secrets.map((secret) => secret.secret))];
45
+ }
39
46
  if (!name) {
40
47
  item.problems = [
41
48
  "the image name could not be derived from the workflow or a GitHub remote",
@@ -0,0 +1,82 @@
1
+ /** Account pages used only in the dedicated automated browser. */
2
+ export const TOKEN_PROVIDERS = Object.freeze({
3
+ "docker-hub": {
4
+ url: "https://app.docker.com/settings/personal-access-tokens/create",
5
+ secret: "DOCKERHUB_TOKEN",
6
+ scope: "Read & Write",
7
+ domains: ["docker.com", "docker.io"],
8
+ },
9
+ "maven-central": {
10
+ url: "https://central.sonatype.com/usertoken",
11
+ secret: "MAVEN_CENTRAL_TOKEN",
12
+ scope: "publish",
13
+ domains: ["sonatype.com"],
14
+ },
15
+ "vscode-marketplace": {
16
+ url: "https://dev.azure.com/_usersSettings/tokens",
17
+ secret: "VSCE_PAT",
18
+ scope: "Marketplace (Manage)",
19
+ domains: ["dev.azure.com", "login.microsoftonline.com"],
20
+ },
21
+ "open-vsx": {
22
+ url: "https://open-vsx.org/user-settings/tokens",
23
+ secret: "OVSX_PAT",
24
+ scope: "publish",
25
+ domains: ["open-vsx.org", "github.com"],
26
+ },
27
+ "chrome-web-store": {
28
+ url: "https://developers.google.com/oauthplayground/",
29
+ secret: "CHROME_WEB_STORE_REFRESH_TOKEN",
30
+ scope: "https://www.googleapis.com/auth/chromewebstore",
31
+ domains: ["google.com"],
32
+ },
33
+ });
34
+
35
+ /** Fill only credential name, publishing scope, repository and expiry. Never widen scope. */
36
+ export function tokenFormScript(provider, name, expiresAt) {
37
+ return `(() => {
38
+ const values = ${JSON.stringify({ name, scope: provider.scope, expires: expiresAt.slice(0, 10) })};
39
+ const fields = { name: ['input[name="name"]', 'input[name="description"]', 'input[name="tokenName"]'], scope: ['select[name="scope"]', 'select[name="permissions"]'], expires: ['input[type="date"]', 'input[name="expires_at"]'] };
40
+ const filled = [];
41
+ for (const [key, selectors] of Object.entries(fields)) {
42
+ const field = selectors.map(selector => document.querySelector(selector)).find(Boolean);
43
+ if (!field) continue;
44
+ if (field.tagName === 'SELECT') {
45
+ const option = [...field.options].find(option => option.textContent.trim() === values[key] || option.value === values[key]);
46
+ if (!option) continue;
47
+ field.value = option.value;
48
+ } else field.value = values[key];
49
+ field.dispatchEvent(new Event('input', { bubbles: true }));
50
+ field.dispatchEvent(new Event('change', { bubbles: true }));
51
+ filled.push(key);
52
+ }
53
+ return { filled };
54
+ })()`;
55
+ }
56
+
57
+ /** Read the one-time value without returning it in any displayed diagnostic. */
58
+ export const READ_TOKEN = `(() => {
59
+ const read = selectors => selectors.map(selector => document.querySelector(selector)).find(Boolean);
60
+ const value = read(['input[data-testid="token-value"]', 'input[name="token"]', 'textarea[name="token"]', '[data-testid="generated-token"]', 'input[name="refresh_token"]']);
61
+ const id = read(['[data-token-id]', 'input[name="token_id"]']);
62
+ const expires = read(['input[name="expires_at"]', '[data-expires-at]']);
63
+ const rawExpiry = expires?.value || expires?.dataset?.expiresAt;
64
+ const expiry = rawExpiry && /^\\d{4}-\\d{2}-\\d{2}$/.test(rawExpiry) ? rawExpiry + 'T00:00:00Z' : rawExpiry;
65
+ return { value: value?.value || value?.textContent?.trim(), id: id?.dataset?.tokenId || id?.value, expires_at: expiry };
66
+ })()`;
67
+
68
+ /** Verify absence only when the token list itself is visible and identifies rows. */
69
+ export function revokedScript(id) {
70
+ if (typeof id !== "string" || id.length > 512) {
71
+ return "false";
72
+ }
73
+ const characters = Array.from(id);
74
+ if (characters.length === 0 || characters.length > 256) {
75
+ return "false";
76
+ }
77
+ // Only numeric literals enter generated code; registry text remains data.
78
+ const points = characters
79
+ .map((character) => character.codePointAt(0))
80
+ .join(",");
81
+ return `(() => { const id = String.fromCodePoint(${points}); const list = document.querySelector('[data-testid="tokens-list"], [data-token-list]'); if (!list) return false; return ![...list.querySelectorAll('[data-token-id]')].some(row => row.dataset.tokenId === id); })()`;
82
+ }
@@ -0,0 +1,91 @@
1
+ /** Registry-specific policy; tokens are never a fallback for OIDC registries. */
2
+ export function credentialPolicy(registry) {
3
+ if (
4
+ ["npm", "pypi", "crates-io", "rubygems", "nuget", "jsr"].includes(registry)
5
+ ) {
6
+ return {
7
+ mode: "trusted",
8
+ description: `${registry}: trusted publishing; remove unused long-lived tokens after verification`,
9
+ };
10
+ }
11
+ if (registry === "ghcr") {
12
+ return {
13
+ mode: "github-token",
14
+ description:
15
+ "ghcr: GITHUB_TOKEN with packages: write; no registry secret",
16
+ };
17
+ }
18
+ if (["go-modules", "packagist"].includes(registry)) {
19
+ return {
20
+ mode: "tags",
21
+ description: `${registry}: public repository tags; no publishing token secret`,
22
+ };
23
+ }
24
+ return {
25
+ mode: "token",
26
+ description: `${registry}: scoped expiring token; gh-manager stores selected-repository organization secrets by default`,
27
+ };
28
+ }
29
+
30
+ /** Unknown expiry also requires attention; GitHub cannot return secret values. */
31
+ export function needsRotation(metadata, now = Date.now(), leadDays = 7) {
32
+ const expires = Date.parse(metadata.expires_at ?? "");
33
+ return (
34
+ metadata.present !== true ||
35
+ metadata.valid === false ||
36
+ !Number.isFinite(expires) ||
37
+ expires <= now + leadDays * 86_400_000
38
+ );
39
+ }
40
+
41
+ /** Provision, store, validate, then revoke. A failed validation preserves the old token. */
42
+ export async function rotateCredential(previous, adapter) {
43
+ let created;
44
+ let stored = false;
45
+ try {
46
+ created = await adapter.create();
47
+ if (
48
+ !created.value ||
49
+ !created.id ||
50
+ !Number.isFinite(Date.parse(created.expires_at)) ||
51
+ Date.parse(created.expires_at) <= Date.now()
52
+ ) {
53
+ throw new Error("registry returned no usable expiring credential");
54
+ }
55
+ stored = true; // A failed ensure can have stored the secret before expiry metadata failed.
56
+ await adapter.store(created);
57
+ await adapter.verify();
58
+ if (previous.token_id && previous.token_id !== created.id) {
59
+ await adapter.revoke(previous.token_id);
60
+ if (!(await adapter.revoked(previous.token_id))) {
61
+ throw new Error("previous token revocation could not be verified");
62
+ }
63
+ }
64
+ return { token_id: created.id, expires_at: created.expires_at };
65
+ } catch (error) {
66
+ if (created?.id && (!stored || adapter.restore)) {
67
+ if (stored) {
68
+ await adapter.restore();
69
+ }
70
+ await adapter.revoke(created.id);
71
+ }
72
+ throw error;
73
+ } finally {
74
+ if (created) {
75
+ created.value = "";
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Token-registry plan; setup obtains the value from its dedicated browser. */
81
+ export function credentialSteps(registry, secret) {
82
+ return [
83
+ {
84
+ id: "manage-registry-token",
85
+ title: `Ensure and rotate ${secret}`,
86
+ kind: "api",
87
+ description: `${credentialPolicy(registry).description}. Check expiry and validity, create the narrowest publishing credential in the browser, store through gh-manager stdin, verify in a dry-run workflow, then revoke and verify the replaced token. Requires gh-manager secret support and a configured verification workflow.`,
88
+ confirm: true,
89
+ },
90
+ ];
91
+ }