package-registry-manager 0.22.7 → 0.22.8

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.
@@ -1,8 +1,14 @@
1
- import { spawn } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { verifyCredentialWorkflow } from "./credential-verification.mjs";
5
- import { needsRotation, rotateCredential } from "./credential-cycle.mjs";
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 { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
6
12
  import {
7
13
  TOKEN_PROVIDERS,
8
14
  tokenFormScript,
@@ -10,42 +16,7 @@ import {
10
16
  revokedScript,
11
17
  } from "./credential-browser.mjs";
12
18
 
13
- /** Exact argv and stdin transport. Suppress all output from secret mutations. */
14
- export function secretCommand(args, input, cwd) {
15
- return new Promise((resolve, reject) => {
16
- const child = spawn("gh-manager", args, {
17
- cwd,
18
- stdio: ["pipe", "pipe", "pipe"],
19
- });
20
- let output = "";
21
- child.stdout.on("data", (chunk) => {
22
- if (input === undefined) {
23
- output += chunk;
24
- }
25
- });
26
- child.stderr.on("data", () => {});
27
- child.on("error", () =>
28
- reject(
29
- new Error(
30
- "gh-manager with secret get-metadata/ensure support is required (gh-manager#6)",
31
- ),
32
- ),
33
- );
34
- child.stdin.on("error", () => {});
35
- child.on("close", (code) =>
36
- code === 0
37
- ? resolve(output)
38
- : reject(
39
- new Error(
40
- "gh-manager secret operation failed; verify installation, permissions and secret support (gh-manager#6)",
41
- ),
42
- ),
43
- );
44
- child.stdin.end(input ?? "");
45
- });
46
- }
47
-
48
- /** Execute the token lifecycle without putting values in argv, diagnostics or files. */
19
+ /** Provision only when GitHub CI reports a missing or broken credential. */
49
20
  export async function setupCredential(session) {
50
21
  const { plan, options } = session;
51
22
  const provider = TOKEN_PROVIDERS[plan.registry];
@@ -62,149 +33,226 @@ export async function setupCredential(session) {
62
33
  throw error;
63
34
  });
64
35
  const settings = JSON.parse(contents).tokens?.[plan.registry] ?? {};
65
- if (!/^[\w.-]+\.ya?ml$/.test(settings.verification_workflow ?? "")) {
66
- throw new Error(
67
- "configure tokens.<registry>.verification_workflow for a reviewed dry-run login workflow before creating credentials",
68
- );
69
- }
70
36
  const { github_owner: owner, github_repository: repository } =
71
37
  plan.repository;
72
38
  if (!owner || !repository) {
73
39
  throw new Error("token setup requires GitHub repository coordinates");
74
40
  }
75
- const secret =
76
- settings.secret ?? plan.package.token_secrets?.[0] ?? provider.secret;
77
- if (!/^[A-Z][A-Z0-9_]*$/.test(secret)) {
78
- throw new Error("invalid registry secret name");
79
- }
41
+ const slug = `${owner}/${repository}`;
42
+ const secret = secretName(
43
+ options.secretName ??
44
+ settings.secret ??
45
+ plan.package.token_secrets?.[0] ??
46
+ provider.secret,
47
+ plan.registry,
48
+ slug,
49
+ );
80
50
  if (settings.level && !["repo", "org"].includes(settings.level)) {
81
51
  throw new Error("token secret level must be repo or org");
82
52
  }
83
- const scope =
84
- settings.level === "repo"
85
- ? ["--repo", `${owner}/${repository}`]
86
- : ["--org", owner];
87
- const visibility =
53
+ const github = options.github ?? githubServices(options);
54
+ const scope = { repo: slug };
55
+ const healthOptions = {
56
+ scope,
57
+ failurePatterns: AUTH_FAILURE_PATTERNS[plan.registry] ?? [],
58
+ inputs: settings.verification_workflow
59
+ ? { prm_nonce: `prm-${Date.now()}` }
60
+ : {},
61
+ };
62
+ const stateFile =
63
+ options.browserProfile &&
64
+ path.join(path.dirname(options.browserProfile), "credential-ids.json");
65
+ const key = `${slug}:${secret}`;
66
+ const targets =
88
67
  settings.level === "repo"
89
- ? []
90
- : ["--visibility", "selected", "--repos", `${owner}/${repository}`];
91
- const workflow = await readFile(
92
- path.join(
93
- options.repository,
94
- ".github/workflows",
95
- settings.verification_workflow,
96
- ),
97
- "utf8",
98
- );
99
- if (
100
- !workflow.includes("workflow_dispatch") ||
101
- !workflow.includes("inputs.prm_nonce") ||
102
- !workflow.includes("run-name:")
103
- ) {
104
- throw new Error(
105
- "verification workflow requires workflow_dispatch input prm_nonce and run-name containing inputs.prm_nonce",
106
- );
107
- }
108
- const days = settings.expiry_days ?? 30;
109
- if (!Number.isInteger(days) || days < 1 || days > 90) {
110
- throw new Error("token expiry_days must be between 1 and 90");
111
- }
112
- if (options.noBrowser) {
113
- throw new Error(
114
- "registry token creation requires the dedicated automated browser",
115
- );
116
- }
117
- if (
118
- options.browserOptions?.attach ||
119
- options.browserOptions?.importScope === "full"
120
- ) {
121
- throw new Error(
122
- "token setup requires a dedicated profile with domain-scoped sign-in import",
123
- );
124
- }
125
- const run = options.secretCommand ?? secretCommand;
126
- const previous = JSON.parse(
127
- await run(
128
- ["secret", "get-metadata", secret, ...scope, "--json"],
129
- undefined,
130
- options.repository,
131
- ),
132
- );
133
- const verify = () =>
134
- verifyCredentialWorkflow(session, settings.verification_workflow);
135
- if (!needsRotation(previous)) {
136
- try {
137
- await verify();
138
- return;
139
- } catch {
140
- previous.valid = false;
141
- }
142
- }
143
- if (
144
- !options.yes &&
145
- !/^y(?:es)?$/i.test(
146
- await session.prompt(
147
- "Create and store a scoped credential, verify it, then revoke the replaced token? [y/N] ",
148
- ),
149
- )
150
- ) {
151
- throw new Error("credential creation declined");
152
- }
153
- if (session.automation) {
154
- await session.automation.close();
155
- }
156
- session.automation = null;
157
- const page = await session.automatedPage(
158
- { ...options.browserOptions, importScope: "domains" },
159
- true,
160
- );
161
- const expiry = new Date(Date.now() + days * 86_400_000).toISOString();
162
- await rotateCredential(previous, {
68
+ ? [slug]
69
+ : (options.secretRepositories?.[`${plan.registry}:${secret}`] ?? [slug]);
70
+ let state = {};
71
+ let page;
72
+ let approved = false;
73
+ let repositorySecretPresent = false;
74
+ const result = await cycleCredential({
75
+ health: () => github.health.health(secret, healthOptions),
76
+ metadata: async () => {
77
+ let metadata = await github.secrets(scope).getMetadata(secret);
78
+ repositorySecretPresent = Boolean(metadata);
79
+ if (!metadata && settings.level !== "repo") {
80
+ try {
81
+ metadata = await github.secrets({ org: owner }).getMetadata(secret);
82
+ } catch (error) {
83
+ if (![403, 404, 422].includes(error.status)) {
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+ if (stateFile) {
89
+ state = JSON.parse(
90
+ await readFile(stateFile, "utf8").catch((error) => {
91
+ if (error.code === "ENOENT") {
92
+ return "{}";
93
+ }
94
+ throw error;
95
+ }),
96
+ );
97
+ }
98
+ const ids = trackedTokenIds(state, targets, secret);
99
+ const currentId =
100
+ settings.token_id ?? state[key]?.at(-1) ?? metadata?.token_id;
101
+ const retained = Object.entries(state).some(
102
+ ([entry, values]) =>
103
+ !targets.some((repo) => entry === `${repo}:${secret}`) &&
104
+ values.includes(currentId),
105
+ );
106
+ return metadata || ids.length
107
+ ? {
108
+ ...metadata,
109
+ present: Boolean(metadata),
110
+ token_id: retained ? undefined : currentId,
111
+ token_ids: ids,
112
+ }
113
+ : null;
114
+ },
163
115
  create: async () => {
164
- await page.goto(provider.url);
165
- await session.prompt(
166
- `Sign in, then press Enter to fill the narrow publishing scope (${provider.scope})...`,
116
+ if (
117
+ options.noBrowser ||
118
+ options.browserOptions?.attach ||
119
+ options.browserOptions?.importScope === "full"
120
+ ) {
121
+ throw new Error(
122
+ "token setup requires a dedicated profile with domain-scoped sign-in import",
123
+ );
124
+ }
125
+ const days = settings.expiry_days ?? 30;
126
+ if (!Number.isInteger(days) || days < 1 || days > 90) {
127
+ throw new Error("token expiry_days must be between 1 and 90");
128
+ }
129
+ if (
130
+ !approved &&
131
+ !options.yes &&
132
+ !/^y(?:es)?$/i.test(
133
+ await session.prompt(
134
+ "Create and store a scoped credential, verify it, then revoke the replaced token? [y/N] ",
135
+ ),
136
+ )
137
+ ) {
138
+ throw new Error("credential creation declined");
139
+ }
140
+ approved = true;
141
+ page ??= await session.automatedPage(
142
+ {
143
+ ...options.browserOptions,
144
+ import: options.browserOptions?.import ?? {
145
+ browser: "auto",
146
+ profile: null,
147
+ },
148
+ importScope: "domains",
149
+ },
150
+ true,
167
151
  );
152
+ await page.goto(provider.url);
168
153
  await page.evaluate(
169
- tokenFormScript(provider, `prm-${repository}`, expiry),
154
+ tokenFormScript(
155
+ provider,
156
+ `prm-${repository}-${secret}`,
157
+ new Date(Date.now() + days * 86_400_000).toISOString(),
158
+ ),
170
159
  );
171
160
  await session.prompt(
172
- `Review the scope and expiry, create the token in this browser, then press Enter (never paste its value)...`,
161
+ `Review the publishing scope (${provider.scope}) and expiry, create the token in this browser, then press Enter (never paste its value)...`,
173
162
  );
174
- const credential = await page.evaluate(READ_TOKEN);
175
- if (!credential.expires_at) {
176
- throw new Error(
177
- "registry did not provide a verifiable expiry; no secret was stored",
178
- );
163
+ return page.evaluate(READ_TOKEN);
164
+ },
165
+ ensure: async (credential, health) => {
166
+ const repos = targets;
167
+ let stored = await github
168
+ .secrets(settings.level === "repo" ? scope : { org: owner })
169
+ .ensure(secret, {
170
+ ...(settings.level === "repo"
171
+ ? {}
172
+ : { repos, visibility: "selected" }),
173
+ health,
174
+ rotateBeforeMs: 0,
175
+ failurePatterns: healthOptions.failurePatterns,
176
+ acquire: async () => ({
177
+ value: credential.value,
178
+ expiresAt: credential.expires_at,
179
+ }),
180
+ });
181
+ if (stored.path === "organization") {
182
+ for (const repo of repos) {
183
+ const override =
184
+ repo === slug
185
+ ? repositorySecretPresent
186
+ : await github.secrets({ repo }).getMetadata(secret);
187
+ if (!override) {
188
+ continue;
189
+ }
190
+ const replacement = await github.secrets({ repo }).ensure(secret, {
191
+ health,
192
+ rotateBeforeMs: 0,
193
+ acquire: async () => ({
194
+ value: credential.value,
195
+ expiresAt: credential.expires_at,
196
+ }),
197
+ });
198
+ if (replacement.valueChanged === false) {
199
+ throw new Error(
200
+ "gh-manager did not replace the repository credential override",
201
+ );
202
+ }
203
+ if (repo === slug) {
204
+ stored = {
205
+ ...replacement,
206
+ path: "repository",
207
+ fallbackReason:
208
+ "An existing repository secret overrides the organization secret; replaced the repository credential too.",
209
+ };
210
+ }
211
+ }
179
212
  }
180
- return credential;
213
+ if (stored.valueChanged === false) {
214
+ throw new Error("gh-manager did not store the replacement credential");
215
+ }
216
+ if (stateFile) {
217
+ recordTokenId(state, targets, secret, credential.id);
218
+ await mkdir(path.dirname(stateFile), { recursive: true });
219
+ await writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
220
+ }
221
+ return stored;
222
+ },
223
+ test: async (stored) => {
224
+ const repos = stored ? targets : [slug];
225
+ const results = await Promise.all(
226
+ repos.map((repo) =>
227
+ github.health.test(secret, { ...healthOptions, scope: { repo } }),
228
+ ),
229
+ );
230
+ return {
231
+ status: results.some((item) => item.status === "auth-failing")
232
+ ? "auth-failing"
233
+ : results.every((item) => item.status === "ok")
234
+ ? "ok"
235
+ : "unknown",
236
+ };
181
237
  },
182
- store: (credential) =>
183
- run(
184
- [
185
- "secret",
186
- "ensure",
187
- secret,
188
- ...scope,
189
- ...visibility,
190
- "--expires-at",
191
- credential.expires_at,
192
- "--token-id",
193
- credential.id,
194
- "--registry",
195
- plan.registry,
196
- ],
197
- credential.value,
198
- options.repository,
199
- ),
200
- verify,
201
238
  revoke: async (id) => {
202
239
  await page.goto(provider.url.replace(/\/create$/, ""));
203
240
  await session.prompt(
204
241
  `Revoke registry token ${id} in this browser, then press Enter...`,
205
242
  );
206
243
  },
207
- revoked: async (id) => page.evaluate(revokedScript(id)),
244
+ revoked: (id) => page.evaluate(revokedScript(id)),
208
245
  });
209
- console.log(" Credential verified; replaced token revocation verified.");
246
+ if (stateFile && result.changed) {
247
+ completeRotation(state, targets, secret, result.token_id);
248
+ await writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
249
+ }
250
+ session.outcome = {
251
+ status: result.changed ? "configured" : "complete",
252
+ secret,
253
+ ...(result.path ? { secret_scope: result.path } : {}),
254
+ ...(result.fallbackReason
255
+ ? { fallback_reason: result.fallbackReason }
256
+ : {}),
257
+ };
210
258
  }
package/src/github.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import {
2
+ createRestClient,
3
+ resolveToken,
4
+ createRepoManager,
5
+ createRunManager,
6
+ createSecretHealth,
7
+ createSecretManager,
8
+ } from "@link-foundation/gh-manager";
9
+
10
+ /** GitHub-only operations, supplied by the installed gh-manager library. */
11
+ export function githubServices(options = {}) {
12
+ const { token } = resolveToken(options);
13
+ if (!token && !options.rest) {
14
+ throw new Error(
15
+ "sign in to GitHub with gh auth login before scanning or setting secrets",
16
+ );
17
+ }
18
+ const rest = options.rest ?? createRestClient({ token });
19
+ const log = {
20
+ debug: (message) => {
21
+ if (options.verbose) {
22
+ console.error(message);
23
+ }
24
+ },
25
+ };
26
+ const context = { rest, log };
27
+ return {
28
+ repos: createRepoManager(context),
29
+ runs: createRunManager(context),
30
+ health: createSecretHealth(context),
31
+ secrets: (scope) => {
32
+ if (typeof scope.repo === "string") {
33
+ const [owner, name, extra] = scope.repo.split("/");
34
+ if (!owner || !name || extra !== undefined) {
35
+ throw new Error("invalid repository secret scope");
36
+ }
37
+ scope = { ...scope, repo: { owner, name } };
38
+ }
39
+ return createSecretManager({ ...context, scope });
40
+ },
41
+ };
42
+ }
package/src/index.mjs CHANGED
@@ -2,5 +2,7 @@ export { npmPrefillScript, openInUserBrowser } from "./browser.mjs";
2
2
  export { inspectRepository } from "./discovery.mjs";
3
3
  export { REGISTRIES, parseRegistry } from "./model.mjs";
4
4
  export { buildPlans, packageDirectory } from "./plan.mjs";
5
+ export { scanRepositories } from "./organization-scan.mjs";
6
+ export { cycleCredential, secretName } from "./ci-credential-cycle.mjs";
5
7
  export { ensureProfileIgnored } from "./profile.mjs";
6
8
  export { defaultBrowserProfile, executePlan, executePlans } from "./setup.mjs";