package-registry-manager 0.21.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/src/skips.mjs ADDED
@@ -0,0 +1,117 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /** The optional per-repository settings file read during inspection. */
5
+ export const CONFIG_FILE = ".package-registry-manager.json";
6
+
7
+ /** Directories that hold tests and examples rather than released packages. */
8
+ export const TEST_DIRECTORIES = Object.freeze([
9
+ "tests",
10
+ "test",
11
+ "fixtures",
12
+ "__fixtures__",
13
+ "__tests__",
14
+ "examples",
15
+ ]);
16
+
17
+ /**
18
+ * Reads the ignore list from `.package-registry-manager.json`, whose
19
+ * `ignore` array holds glob patterns (`*`, `**`, `?`) relative to the root.
20
+ */
21
+ export async function readIgnoreList(root) {
22
+ let contents;
23
+ try {
24
+ contents = await readFile(path.join(root, CONFIG_FILE), "utf8");
25
+ } catch (error) {
26
+ if (error.code === "ENOENT") {
27
+ return [];
28
+ }
29
+ throw error;
30
+ }
31
+ let config;
32
+ try {
33
+ config = JSON.parse(contents);
34
+ } catch (error) {
35
+ throw new Error(`invalid JSON in ${CONFIG_FILE}: ${error.message}`, {
36
+ cause: error,
37
+ });
38
+ }
39
+ const ignore = config?.ignore ?? [];
40
+ if (
41
+ !Array.isArray(ignore) ||
42
+ !ignore.every((item) => typeof item === "string")
43
+ ) {
44
+ throw new Error(`${CONFIG_FILE}: "ignore" must be an array of strings`);
45
+ }
46
+ return ignore;
47
+ }
48
+
49
+ /** Returns the first ignore pattern that matches a path or one of its parents. */
50
+ export function ignoredBy(ignore, manifest) {
51
+ const segments = manifest.split("/");
52
+ const prefixes = segments.map((_, index) =>
53
+ segments.slice(0, index + 1).join("/"),
54
+ );
55
+ return (
56
+ ignore.find((pattern) => {
57
+ const expression = globExpression(pattern);
58
+ return prefixes.some((prefix) => expression.test(prefix));
59
+ }) ?? null
60
+ );
61
+ }
62
+
63
+ /** Returns the test or example directory that holds a path, if any. */
64
+ export function testDirectory(manifest) {
65
+ return (
66
+ manifest
67
+ .split("/")
68
+ .slice(0, -1)
69
+ .find((segment) => TEST_DIRECTORIES.includes(segment)) ?? null
70
+ );
71
+ }
72
+
73
+ /** Reports whether any workflow mentions the directory that holds a file. */
74
+ export function referencedByWorkflow(workflows, manifest) {
75
+ const directory = path.posix.dirname(manifest);
76
+ return workflows.some((workflow) =>
77
+ workflow.contents
78
+ .split(/\r?\n/)
79
+ .some((line) => !/^\s*#/.test(line) && line.includes(directory)),
80
+ );
81
+ }
82
+
83
+ /** Explains why a manifest in a test or example directory was skipped. */
84
+ export function testDirectoryReason(directory) {
85
+ return `under ${directory}/, a test or example directory, and no workflow publishes it`;
86
+ }
87
+
88
+ /** Explains why a manifest matched by the ignore list was skipped. */
89
+ export function ignoreReason(pattern) {
90
+ return `ignored by "${pattern}" in ${CONFIG_FILE}`;
91
+ }
92
+
93
+ function globExpression(pattern) {
94
+ const normalized = pattern
95
+ .trim()
96
+ .replace(/^\.?\/+/, "")
97
+ .replace(/\/+$/, "");
98
+ let source = "";
99
+ for (let index = 0; index < normalized.length; index += 1) {
100
+ const character = normalized[index];
101
+ if (character === "*" && normalized[index + 1] === "*") {
102
+ source += ".*";
103
+ index += 1;
104
+ if (normalized[index + 1] === "/") {
105
+ source = `${source.slice(0, -2)}(?:.*/)?`;
106
+ index += 1;
107
+ }
108
+ } else if (character === "*") {
109
+ source += "[^/]*";
110
+ } else if (character === "?") {
111
+ source += "[^/]";
112
+ } else {
113
+ source += character.replace(/[.+^${}()|[\]\\]/g, "\\$&");
114
+ }
115
+ }
116
+ return new RegExp(`^${source}$`);
117
+ }
package/src/tokens.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // Long-lived registry tokens: the repository secrets that trusted publishing
2
+ // replaces, and the one-time crates.io first-publish token, whose revocation
3
+ // is verified against the crates.io API before setup ends.
4
+ import { readFile } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { registryTokenSecrets } from "./publishers.mjs";
9
+ import { registryEndpoint, USER_AGENT } from "./registry-state.mjs";
10
+
11
+ /** Where the crates.io first-publish token is revoked. */
12
+ export const CRATES_TOKENS_URL = "https://crates.io/settings/tokens";
13
+
14
+ /**
15
+ * Steps that list the repository's secrets and delete the long-lived
16
+ * registry tokens trusted publishing makes unnecessary, one confirmation each.
17
+ */
18
+ export function tokenSecretSteps(packageInfo, slug) {
19
+ const names = registryTokenSecrets(packageInfo.registry);
20
+ return [
21
+ {
22
+ id: "audit-token-secrets",
23
+ title: "Look for leftover long-lived token secrets",
24
+ kind: "check",
25
+ description: `Trusted publishing makes long-lived tokens such as ${names.join(", ")} unnecessary. Organization secrets are not listed here; an organization owner removes them with gh secret delete --org.`,
26
+ command: {
27
+ program: "gh",
28
+ args: ["secret", "list", "--repo", slug, "--json", "name"],
29
+ },
30
+ cwd: ".",
31
+ },
32
+ {
33
+ id: "delete-token-secret",
34
+ title: "Delete the leftover token secrets",
35
+ kind: "command",
36
+ description:
37
+ "Remove each long-lived token secret that no workflow reads any more.",
38
+ command: {
39
+ program: "gh",
40
+ args: ["secret", "delete", "{token_secret}", "--repo", slug],
41
+ },
42
+ when: "token-secret-present",
43
+ cwd: ".",
44
+ confirm: true,
45
+ },
46
+ ];
47
+ }
48
+
49
+ /**
50
+ * Splits the registry token secrets in `gh secret list --json name` output
51
+ * into the ones a workflow still reads, which must be replaced by trusted
52
+ * publishing first, and leftovers that can be deleted.
53
+ */
54
+ export function auditRegistryTokens(output, packageInfo) {
55
+ const listed = new Set(
56
+ JSON.parse(String(output || "[]")).map((item) => item.name),
57
+ );
58
+ const present = registryTokenSecrets(packageInfo.registry).filter((name) =>
59
+ listed.has(name),
60
+ );
61
+ const read = new Set(packageInfo.token_secrets ?? []);
62
+ return {
63
+ inUse: present.filter((name) => read.has(name)),
64
+ leftover: present.filter((name) => !read.has(name)),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Prints which registry token secrets remain and returns the leftovers, the
70
+ * ones no workflow reads any more and that can be deleted.
71
+ */
72
+ export function reportTokenSecrets(output, packageInfo) {
73
+ const { inUse, leftover } = auditRegistryTokens(output, packageInfo);
74
+ for (const name of inUse) {
75
+ console.log(
76
+ ` ${name} is still read by a workflow; switch that workflow to trusted publishing before deleting it.`,
77
+ );
78
+ }
79
+ for (const name of leftover) {
80
+ console.log(` ${name} is no longer needed with trusted publishing.`);
81
+ }
82
+ return leftover;
83
+ }
84
+
85
+ /** Returns cargo's home directory, as cargo resolves it. */
86
+ export function cargoHome(env = process.env, home = os.homedir()) {
87
+ return env.CARGO_HOME || path.join(home, ".cargo");
88
+ }
89
+
90
+ /**
91
+ * Reads the crates.io token `cargo login` stored, or `undefined` when cargo
92
+ * keeps it elsewhere (a credential provider) or holds none.
93
+ */
94
+ export async function readCargoToken(directory = cargoHome()) {
95
+ for (const name of ["credentials.toml", "credentials"]) {
96
+ const contents = await readFile(path.join(directory, name), "utf8").catch(
97
+ () => "",
98
+ );
99
+ const token = registryToken(contents);
100
+ if (token) {
101
+ return token;
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** Extracts `token` from the `[registry]` table of cargo's credentials. */
108
+ export function registryToken(contents) {
109
+ let table = "";
110
+ for (const line of contents.split(/\r?\n/)) {
111
+ const header = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line);
112
+ if (header) {
113
+ table = header[1].trim();
114
+ continue;
115
+ }
116
+ const value = /^\s*token\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(line);
117
+ if (table === "registry" && value) {
118
+ return value[1] ?? value[2];
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /**
125
+ * Classifies the crates.io answer to a token on an endpoint only the website
126
+ * session may use: `revoked` when crates.io no longer knows the token,
127
+ * `active` when it still authenticates, `undefined` when unclear.
128
+ */
129
+ export function tokenState(status, body) {
130
+ const detail = (body?.errors ?? [])
131
+ .map((error) => String(error?.detail ?? ""))
132
+ .join(" ");
133
+ if (
134
+ (status === 401 || status === 403) &&
135
+ /authentication failed|does not match the format/i.test(detail)
136
+ ) {
137
+ return "revoked";
138
+ }
139
+ if (
140
+ (status >= 200 && status < 300) ||
141
+ /only be performed on the crates\.io website/i.test(detail)
142
+ ) {
143
+ return "active";
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ /**
149
+ * Asks crates.io whether a token still authenticates. The token is sent only
150
+ * to the crates.io API and never printed.
151
+ */
152
+ export async function checkCratesToken(base, token, options = {}) {
153
+ const fetcher = options.fetch ?? globalThis.fetch;
154
+ const url = `${base}/me/tokens`;
155
+ try {
156
+ const response = await fetcher(url, {
157
+ headers: {
158
+ accept: "application/json",
159
+ authorization: token,
160
+ "user-agent": USER_AGENT,
161
+ },
162
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
163
+ });
164
+ if (options.verbose) {
165
+ console.error(`GET ${url} -> ${response.status}`);
166
+ }
167
+ const body = await response.json().catch(() => undefined);
168
+ return tokenState(response.status, body);
169
+ } catch (error) {
170
+ if (options.verbose) {
171
+ console.error(`GET ${url} failed: ${error.message}`);
172
+ }
173
+ return undefined;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Confirms that crates.io rejects the first-publish token `cargo login`
179
+ * stored, asking the maintainer to revoke it and re-checking up to
180
+ * `attempts` times. `ask` waits for the maintainer.
181
+ */
182
+ export async function verifyTokenRevoked(ask, options = {}) {
183
+ const token = await readCargoToken(cargoHome(options.env ?? process.env));
184
+ if (!token) {
185
+ console.error(
186
+ `warning: cargo holds no readable crates.io token, so its revocation cannot be verified; check ${CRATES_TOKENS_URL}`,
187
+ );
188
+ return;
189
+ }
190
+ const base = registryEndpoint("crates-io", options.env ?? process.env);
191
+ const attempts = options.attempts ?? 3;
192
+ for (let attempt = 1; ; attempt += 1) {
193
+ const state = await checkCratesToken(base, token, options);
194
+ if (state === "revoked") {
195
+ console.log(
196
+ " crates.io rejects the first-publish token: it is revoked.",
197
+ );
198
+ return;
199
+ }
200
+ if (state === undefined) {
201
+ console.error(
202
+ `warning: crates.io gave no clear answer about the first-publish token; check that it is revoked at ${CRATES_TOKENS_URL}`,
203
+ );
204
+ return;
205
+ }
206
+ if (attempt >= attempts) {
207
+ throw new Error(
208
+ `the first-publish token still authenticates on crates.io; revoke it at ${CRATES_TOKENS_URL}`,
209
+ );
210
+ }
211
+ await ask(
212
+ `crates.io still accepts the first-publish token. Revoke it at ${CRATES_TOKENS_URL}, then press Enter...`,
213
+ );
214
+ }
215
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // Container registries; npm, crates.io, and PyPI use the job-aware detection
5
+ // in publishers.mjs.
6
+ const PUBLISH_PATTERNS = new Map([
7
+ ["docker-hub", /\bDOCKER_?HUB_|\bdocker\.io\/|hub\.docker\.com/i],
8
+ ["ghcr", /\bghcr\.io\b/],
9
+ ]);
10
+
11
+ /** Reads GitHub Actions workflow files sorted by filename. */
12
+ export async function readWorkflows(root) {
13
+ const directory = path.join(root, ".github/workflows");
14
+ let entries;
15
+ try {
16
+ entries = await readdir(directory, { withFileTypes: true });
17
+ } catch {
18
+ return [];
19
+ }
20
+ const names = entries
21
+ .filter((entry) => entry.isFile() && /\.ya?ml$/.test(entry.name))
22
+ .map((entry) => entry.name)
23
+ .sort((left, right) => left.localeCompare(right));
24
+ return Promise.all(
25
+ names.map(async (name) => ({
26
+ name,
27
+ contents: await readFile(path.join(directory, name), "utf8"),
28
+ })),
29
+ );
30
+ }
31
+
32
+ /** Returns the first workflow that publishes to the registry, if any. */
33
+ export function publishingWorkflow(workflows, registry) {
34
+ const pattern = PUBLISH_PATTERNS.get(registry);
35
+ return pattern
36
+ ? (workflows.find((workflow) => pattern.test(workflow.contents)) ?? null)
37
+ : null;
38
+ }
39
+
40
+ /** Reports whether a workflow grants `packages: write` to its token. */
41
+ export function grantsPackagesWrite(contents) {
42
+ return (
43
+ /^\s*packages\s*:\s*['"]?write\b/m.test(contents) ||
44
+ /^\s*permissions\s*:\s*['"]?write-all\b/m.test(contents)
45
+ );
46
+ }