package-registry-manager 0.22.6 → 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 +4 -3
- 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/discovery.mjs +2 -1
- package/src/github.mjs +42 -0
- package/src/index.mjs +2 -0
- package/src/manifest-proposal.mjs +201 -0
- package/src/organization-scan.mjs +259 -0
- package/src/organization-setup.mjs +203 -0
- package/src/plan.mjs +17 -1
- package/src/publishing-policy.mjs +46 -0
- package/src/registry-state.mjs +107 -7
- package/src/repository-identity.mjs +317 -0
- package/src/repository-repair.mjs +375 -0
- package/src/setup-batch.mjs +54 -4
- package/src/setup.mjs +30 -3
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/discovery.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { CONTAINER_FILES, containerPackages } from "./containers.mjs";
|
|
5
5
|
import { packageWorkflows, auditWrappers } from "./package-coverage.mjs";
|
|
6
6
|
import { REGISTRIES } from "./model.mjs";
|
|
7
|
+
import { inspectManifestRepositories } from "./repository-identity.mjs";
|
|
7
8
|
import {
|
|
8
9
|
TRUSTED_REGISTRIES,
|
|
9
10
|
detectPublisher,
|
|
@@ -214,7 +215,7 @@ export async function inspectRepository(repository, options = {}) {
|
|
|
214
215
|
left.manifest.localeCompare(right.manifest),
|
|
215
216
|
);
|
|
216
217
|
}
|
|
217
|
-
return inspection;
|
|
218
|
+
return inspectManifestRepositories(inspection);
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
/**
|
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";
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { exec } from "command-stream";
|
|
5
|
+
import {
|
|
6
|
+
githubSlug,
|
|
7
|
+
manifestUrls,
|
|
8
|
+
repositorySlug,
|
|
9
|
+
} from "./repository-identity.mjs";
|
|
10
|
+
|
|
11
|
+
function runner(options) {
|
|
12
|
+
return (
|
|
13
|
+
options.run ??
|
|
14
|
+
(async (program, args, cwd = options.repository) => {
|
|
15
|
+
const result = await exec(program, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
env: process.env,
|
|
18
|
+
capture: true,
|
|
19
|
+
mirror: false,
|
|
20
|
+
stdin: "ignore",
|
|
21
|
+
});
|
|
22
|
+
if (result.code !== 0) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${program} failed while preparing the manifest repository proposal`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return String(result.stdout ?? "").trim();
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeManifest(manifest) {
|
|
33
|
+
if (
|
|
34
|
+
!manifest ||
|
|
35
|
+
manifest
|
|
36
|
+
.split("/")
|
|
37
|
+
.some((part) => !part || part === "." || part === "..") ||
|
|
38
|
+
path.isAbsolute(manifest) ||
|
|
39
|
+
manifest.includes("\\")
|
|
40
|
+
) {
|
|
41
|
+
throw new Error("unsafe manifest path for repository repair");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Change only stale repository values; preserve npm's repository.directory. */
|
|
46
|
+
export function manifestRepositoryProposal(contents, manifest, slug) {
|
|
47
|
+
if (!githubSlug(slug)) {
|
|
48
|
+
throw new Error("invalid canonical repository");
|
|
49
|
+
}
|
|
50
|
+
const urls = manifestUrls(contents, manifest);
|
|
51
|
+
const stale = urls.filter(
|
|
52
|
+
(url) =>
|
|
53
|
+
githubSlug(url) && githubSlug(url).toLowerCase() !== slug.toLowerCase(),
|
|
54
|
+
);
|
|
55
|
+
if (!stale.length) {
|
|
56
|
+
return contents;
|
|
57
|
+
}
|
|
58
|
+
const url = `https://github.com/${slug}`;
|
|
59
|
+
if (manifest.endsWith("package.json")) {
|
|
60
|
+
const document = JSON.parse(contents.replace(/^\uFEFF/, ""));
|
|
61
|
+
if (typeof document.repository === "string") {
|
|
62
|
+
document.repository = url;
|
|
63
|
+
} else {
|
|
64
|
+
document.repository.url = `git+${url}.git`;
|
|
65
|
+
}
|
|
66
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
67
|
+
}
|
|
68
|
+
let proposal = contents;
|
|
69
|
+
for (const old of stale) {
|
|
70
|
+
proposal = proposal.replaceAll(old, old.replace(githubSlug(old), slug));
|
|
71
|
+
}
|
|
72
|
+
return proposal;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Check remote metadata before retrying; the original run may use an old SHA. */
|
|
76
|
+
export async function verifyRemoteManifest(plan, options, reference) {
|
|
77
|
+
safeManifest(plan.package.manifest);
|
|
78
|
+
const run = runner(options);
|
|
79
|
+
const slug = repositorySlug(plan.repository);
|
|
80
|
+
const ref =
|
|
81
|
+
reference ??
|
|
82
|
+
(await run("gh", ["api", `repos/${slug}`, "--jq", ".default_branch"]));
|
|
83
|
+
if (!ref || ref.startsWith("-") || /[\s:]/.test(ref)) {
|
|
84
|
+
throw new Error("invalid remote manifest reference");
|
|
85
|
+
}
|
|
86
|
+
await run("git", ["fetch", "origin", ref]);
|
|
87
|
+
const contents = await run("git", [
|
|
88
|
+
"show",
|
|
89
|
+
`FETCH_HEAD:${plan.package.manifest}`,
|
|
90
|
+
]);
|
|
91
|
+
return (
|
|
92
|
+
manifestRepositoryProposal(contents, plan.package.manifest, slug) ===
|
|
93
|
+
contents
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Offer a reviewed branch and draft PR from the current remote default branch. */
|
|
98
|
+
export async function offerManifestRepository(plan, options) {
|
|
99
|
+
const run = runner(options);
|
|
100
|
+
const slug = repositorySlug(plan.repository);
|
|
101
|
+
const manifest = plan.package.manifest;
|
|
102
|
+
safeManifest(manifest);
|
|
103
|
+
const remote = githubSlug(await run("git", ["remote", "get-url", "origin"]));
|
|
104
|
+
if (!remote) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"origin must be a GitHub repository for the manifest proposal",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const canonical = await run("gh", [
|
|
110
|
+
"api",
|
|
111
|
+
`repos/${remote}`,
|
|
112
|
+
"--jq",
|
|
113
|
+
".full_name",
|
|
114
|
+
]);
|
|
115
|
+
if (canonical.toLowerCase() !== slug.toLowerCase()) {
|
|
116
|
+
throw new Error("origin must resolve to the repository being repaired");
|
|
117
|
+
}
|
|
118
|
+
const base = await run("gh", [
|
|
119
|
+
"api",
|
|
120
|
+
`repos/${slug}`,
|
|
121
|
+
"--jq",
|
|
122
|
+
".default_branch",
|
|
123
|
+
]);
|
|
124
|
+
if (!base || base.startsWith("-") || /[\s:]/.test(base)) {
|
|
125
|
+
throw new Error("invalid default branch");
|
|
126
|
+
}
|
|
127
|
+
await run("git", ["fetch", "origin", base]);
|
|
128
|
+
const contents = await run("git", ["show", `FETCH_HEAD:${manifest}`]);
|
|
129
|
+
const proposal = manifestRepositoryProposal(contents, manifest, slug);
|
|
130
|
+
if (proposal === contents) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
console.log(`Proposed ${manifest}:\n${proposal}`);
|
|
134
|
+
if (
|
|
135
|
+
!options.yes &&
|
|
136
|
+
!/^y(?:es)?$/i.test(
|
|
137
|
+
await options.prompt(
|
|
138
|
+
"Create a branch and draft pull request with this repository URL fix? [y/N] ",
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
) {
|
|
142
|
+
return { status: "blocked" };
|
|
143
|
+
}
|
|
144
|
+
const branch = `prm/repository-url-${Date.now()}`;
|
|
145
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-manifest-"));
|
|
146
|
+
const checkout = path.join(temporary, "checkout");
|
|
147
|
+
let created = false;
|
|
148
|
+
try {
|
|
149
|
+
await run("git", ["worktree", "add", "-b", branch, checkout, "FETCH_HEAD"]);
|
|
150
|
+
created = true;
|
|
151
|
+
const target = path.join(checkout, manifest);
|
|
152
|
+
const root = await realpath(checkout);
|
|
153
|
+
const physical = await realpath(target);
|
|
154
|
+
if (
|
|
155
|
+
!physical.startsWith(`${root}${path.sep}`) ||
|
|
156
|
+
physical !== path.join(root, manifest)
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"manifest must stay inside the proposal worktree without symlinks",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const current = await readFile(target, "utf8");
|
|
163
|
+
const updated = manifestRepositoryProposal(current, manifest, slug);
|
|
164
|
+
await writeFile(target, updated);
|
|
165
|
+
await run("git", ["add", "--", manifest], checkout);
|
|
166
|
+
await run(
|
|
167
|
+
"git",
|
|
168
|
+
["commit", "-m", "Fix manifest repository URL after repository transfer"],
|
|
169
|
+
checkout,
|
|
170
|
+
);
|
|
171
|
+
await run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], checkout);
|
|
172
|
+
const url = await run(
|
|
173
|
+
"gh",
|
|
174
|
+
[
|
|
175
|
+
"pr",
|
|
176
|
+
"create",
|
|
177
|
+
"--repo",
|
|
178
|
+
slug,
|
|
179
|
+
"--base",
|
|
180
|
+
base,
|
|
181
|
+
"--head",
|
|
182
|
+
branch,
|
|
183
|
+
"--draft",
|
|
184
|
+
"--title",
|
|
185
|
+
"Fix manifest repository URL after repository transfer",
|
|
186
|
+
"--body",
|
|
187
|
+
`Update ${manifest} to name ${slug}. Review and merge this change, then re-run package-registry-manager setup to verify the repository metadata and retry the release.`,
|
|
188
|
+
],
|
|
189
|
+
checkout,
|
|
190
|
+
);
|
|
191
|
+
console.log(
|
|
192
|
+
`Review and merge ${url}, then re-run setup. Release retries are paused.`,
|
|
193
|
+
);
|
|
194
|
+
return { status: "manifest-pr", url };
|
|
195
|
+
} finally {
|
|
196
|
+
if (created) {
|
|
197
|
+
await run("git", ["worktree", "remove", "--force", checkout]);
|
|
198
|
+
}
|
|
199
|
+
await rm(temporary, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|