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.
- package/README.md +14 -0
- package/package.json +3 -2
- package/src/ci-credential-cycle.mjs +121 -0
- package/src/cli.mjs +72 -1
- package/src/credential-cycle.mjs +1 -1
- package/src/credential-setup.mjs +209 -161
- package/src/github.mjs +42 -0
- package/src/index.mjs +2 -0
- package/src/organization-scan.mjs +259 -0
- package/src/organization-setup.mjs +203 -0
- package/src/publishing-policy.mjs +46 -0
- package/src/registry-state.mjs +22 -0
- package/src/setup-batch.mjs +53 -4
- package/src/setup.mjs +1 -0
package/src/credential-setup.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
: [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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:
|
|
244
|
+
revoked: (id) => page.evaluate(revokedScript(id)),
|
|
208
245
|
});
|
|
209
|
-
|
|
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";
|