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/CHANGELOG.md +60 -0
- package/LICENSE +24 -0
- package/README.md +21 -0
- package/package.json +53 -0
- package/src/approvals.mjs +72 -0
- package/src/auth-urls.mjs +149 -0
- package/src/automation.mjs +124 -0
- package/src/browser-options.mjs +175 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +431 -0
- package/src/containers.mjs +66 -0
- package/src/default-browser.mjs +149 -0
- package/src/discovery.mjs +461 -0
- package/src/flows.mjs +722 -0
- package/src/index.mjs +6 -0
- package/src/model.mjs +50 -0
- package/src/pages.mjs +91 -0
- package/src/plan.mjs +243 -0
- package/src/prerequisites.mjs +323 -0
- package/src/profile.mjs +104 -0
- package/src/publishers.mjs +495 -0
- package/src/registry-state.mjs +158 -0
- package/src/setup.mjs +820 -0
- package/src/skips.mjs +117 -0
- package/src/tokens.mjs +215 -0
- package/src/workflows.mjs +46 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { exec } from "command-stream";
|
|
2
|
+
|
|
3
|
+
import { automatedDescription } from "./browser-options.mjs";
|
|
4
|
+
import { getJson, registryEndpoint } from "./registry-state.mjs";
|
|
5
|
+
|
|
6
|
+
/** The Node.js versions npm 11 runs on. */
|
|
7
|
+
export const NPM_11_ENGINES = "^20.17.0 || >=22.9.0";
|
|
8
|
+
/** The Node.js versions npm 12 runs on. */
|
|
9
|
+
export const NPM_12_ENGINES = "^22.22.2 || ^24.15.0 || >=26.0.0";
|
|
10
|
+
/** Where an npm account turns on two-factor authentication. */
|
|
11
|
+
export const NPM_TFA_URL =
|
|
12
|
+
"https://docs.npmjs.com/configuring-two-factor-authentication/";
|
|
13
|
+
/** The npm that runs `npm trust` when Node.js cannot run npm 12. */
|
|
14
|
+
export const DEFAULT_TRUST_NPM = "npm@^11.10";
|
|
15
|
+
|
|
16
|
+
/** Parses `v24.15.0` or `24.15.0` into numbers, or returns null. */
|
|
17
|
+
export function parseVersion(text) {
|
|
18
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? "").trim());
|
|
19
|
+
return match ? match.slice(1, 4).map(Number) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function atLeast(version, minimum) {
|
|
23
|
+
for (let index = 0; index < 3; index += 1) {
|
|
24
|
+
if (version[index] !== minimum[index]) {
|
|
25
|
+
return version[index] > minimum[index];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a Node.js version satisfies npm 11's engines. */
|
|
32
|
+
export function supportsNpm11(nodeVersion) {
|
|
33
|
+
const version = parseVersion(nodeVersion);
|
|
34
|
+
return Boolean(
|
|
35
|
+
version &&
|
|
36
|
+
((version[0] === 20 && atLeast(version, [20, 17, 0])) ||
|
|
37
|
+
atLeast(version, [22, 9, 0])),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a Node.js version satisfies npm 12's engines. */
|
|
42
|
+
export function supportsNpm12(nodeVersion) {
|
|
43
|
+
const version = parseVersion(nodeVersion);
|
|
44
|
+
return Boolean(
|
|
45
|
+
version &&
|
|
46
|
+
((version[0] === 22 && atLeast(version, [22, 22, 2])) ||
|
|
47
|
+
(version[0] === 24 && atLeast(version, [24, 15, 0])) ||
|
|
48
|
+
version[0] >= 26),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The npm package spec that runs `npm trust` through npx: npm 12 only when the
|
|
54
|
+
* Node.js on PATH satisfies its engines, otherwise npm 11.10 or newer.
|
|
55
|
+
*/
|
|
56
|
+
export function trustNpmSpec(nodeVersion) {
|
|
57
|
+
return supportsNpm12(nodeVersion) ? "npm@^12" : DEFAULT_TRUST_NPM;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The version a `npm@^N` spec resolves to, from the registry's `next-N`
|
|
62
|
+
* dist-tag (or `latest` when it has the same major version).
|
|
63
|
+
*/
|
|
64
|
+
export function resolveTrustNpm(spec, distTags) {
|
|
65
|
+
const major = /\^(\d+)/.exec(spec)?.[1];
|
|
66
|
+
if (!major || !distTags) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const candidates = [distTags[`next-${major}`], distTags.latest];
|
|
70
|
+
return (
|
|
71
|
+
candidates.find((version) => parseVersion(version)?.[0] === +major) ?? null
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads `tfa` from `npm profile get --json`: the 2FA mode, or null when
|
|
77
|
+
* two-factor authentication is off or still pending. Throws on bad JSON.
|
|
78
|
+
*/
|
|
79
|
+
export function twoFactorMode(output) {
|
|
80
|
+
const tfa = JSON.parse(String(output)).tfa;
|
|
81
|
+
return tfa && typeof tfa === "object" && !tfa.pending && tfa.mode
|
|
82
|
+
? String(tfa.mode)
|
|
83
|
+
: null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Summarizes `gh auth status --json hosts`, or returns null. */
|
|
87
|
+
export function githubAuth(output) {
|
|
88
|
+
let hosts;
|
|
89
|
+
try {
|
|
90
|
+
hosts = JSON.parse(String(output)).hosts ?? {};
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const accounts = [
|
|
95
|
+
...(hosts["github.com"] ?? []),
|
|
96
|
+
...Object.entries(hosts)
|
|
97
|
+
.filter(([host]) => host !== "github.com")
|
|
98
|
+
.flatMap(([, entries]) => entries),
|
|
99
|
+
];
|
|
100
|
+
const account =
|
|
101
|
+
accounts.find((entry) => entry.active && entry.state === "success") ?? null;
|
|
102
|
+
if (!account) {
|
|
103
|
+
return { login: null, scopes: [] };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
login: account.login ?? null,
|
|
107
|
+
scopes: String(account.scopes ?? "")
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((scope) => scope.trim())
|
|
110
|
+
.filter(Boolean),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function probe(program, args, verbose) {
|
|
115
|
+
if (verbose) {
|
|
116
|
+
console.error(`+ ${[program, ...args].join(" ")}`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const result = await exec(program, args, {
|
|
120
|
+
capture: true,
|
|
121
|
+
mirror: false,
|
|
122
|
+
stdin: "ignore",
|
|
123
|
+
});
|
|
124
|
+
return { code: result.code, stdout: String(result.stdout ?? "") };
|
|
125
|
+
} catch {
|
|
126
|
+
return { code: 127, stdout: "" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Looks up the local tools the setup flows depend on: the Node.js and npm on
|
|
132
|
+
* PATH, the npm that runs `npm trust`, the npm account's 2FA state, and the
|
|
133
|
+
* GitHub CLI's scopes. Network lookups are skipped with `offline`.
|
|
134
|
+
*/
|
|
135
|
+
export async function probeEnvironment(options = {}) {
|
|
136
|
+
const { offline = false, verbose = false, npm = true } = options;
|
|
137
|
+
const [node, npmVersion, profile, gh] = await Promise.all([
|
|
138
|
+
probe("node", ["--version"], verbose),
|
|
139
|
+
npm ? probe("npm", ["--version"], verbose) : null,
|
|
140
|
+
npm && !offline
|
|
141
|
+
? probe("npm", ["profile", "get", "--json"], verbose)
|
|
142
|
+
: null,
|
|
143
|
+
offline
|
|
144
|
+
? null
|
|
145
|
+
: probe("gh", ["auth", "status", "--json", "hosts"], verbose),
|
|
146
|
+
]);
|
|
147
|
+
const nodeVersion = node.code === 0 ? node.stdout.trim() : null;
|
|
148
|
+
const spec = trustNpmSpec(nodeVersion);
|
|
149
|
+
const distTags =
|
|
150
|
+
npm && !offline
|
|
151
|
+
? await getJson(`${registryEndpoint("npm")}/-/package/npm/dist-tags`, {
|
|
152
|
+
verbose,
|
|
153
|
+
})
|
|
154
|
+
: null;
|
|
155
|
+
return {
|
|
156
|
+
offline,
|
|
157
|
+
node: nodeVersion,
|
|
158
|
+
npm: npmVersion?.code === 0 ? npmVersion.stdout.trim() : null,
|
|
159
|
+
trustNpm: spec,
|
|
160
|
+
trustNpmVersion: resolveTrustNpm(spec, distTags),
|
|
161
|
+
twoFactor: twoFactorState(profile),
|
|
162
|
+
github: githubState(gh),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function twoFactorState(result) {
|
|
167
|
+
if (!result) {
|
|
168
|
+
return { state: "unchecked" };
|
|
169
|
+
}
|
|
170
|
+
if (result.code !== 0) {
|
|
171
|
+
return { state: "unknown" };
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const mode = twoFactorMode(result.stdout);
|
|
175
|
+
return mode ? { state: "enabled", mode } : { state: "off" };
|
|
176
|
+
} catch {
|
|
177
|
+
return { state: "unknown" };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function githubState(result) {
|
|
182
|
+
if (!result) {
|
|
183
|
+
return { state: "unchecked" };
|
|
184
|
+
}
|
|
185
|
+
if (result.code === 127) {
|
|
186
|
+
return { state: "missing" };
|
|
187
|
+
}
|
|
188
|
+
const auth = githubAuth(result.stdout);
|
|
189
|
+
if (!auth) {
|
|
190
|
+
return { state: "unknown" };
|
|
191
|
+
}
|
|
192
|
+
return auth.login
|
|
193
|
+
? { state: "signed-in", login: auth.login, scopes: auth.scopes }
|
|
194
|
+
: { state: "signed-out" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const prerequisite = (id, title, detected, required, ok) => ({
|
|
198
|
+
id,
|
|
199
|
+
title,
|
|
200
|
+
detected,
|
|
201
|
+
required,
|
|
202
|
+
ok,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The manual prerequisites of a plan, listed before its steps: tool versions,
|
|
207
|
+
* the npm account's 2FA state, the GitHub CLI scopes, and where browser pages
|
|
208
|
+
* open. `browser` is `{ mode, channel, profile }` with mode `default`,
|
|
209
|
+
* `automated`, or `none`.
|
|
210
|
+
*/
|
|
211
|
+
export function planPrerequisites(plan, environment, browser = {}) {
|
|
212
|
+
if (!environment || plan.steps.length === 0) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const items = [];
|
|
216
|
+
const unchecked = environment.offline
|
|
217
|
+
? "not checked (--offline)"
|
|
218
|
+
: "not checked";
|
|
219
|
+
if (plan.registry === "npm") {
|
|
220
|
+
const node = environment.node;
|
|
221
|
+
const nodeOk = node ? supportsNpm11(node) : false;
|
|
222
|
+
items.push(
|
|
223
|
+
prerequisite(
|
|
224
|
+
"node",
|
|
225
|
+
"Node.js",
|
|
226
|
+
node ?? "not found",
|
|
227
|
+
NPM_11_ENGINES,
|
|
228
|
+
nodeOk,
|
|
229
|
+
),
|
|
230
|
+
prerequisite(
|
|
231
|
+
"npm",
|
|
232
|
+
"npm",
|
|
233
|
+
environment.npm ?? "not found",
|
|
234
|
+
"installed",
|
|
235
|
+
Boolean(environment.npm),
|
|
236
|
+
),
|
|
237
|
+
prerequisite(
|
|
238
|
+
"npm-trust",
|
|
239
|
+
"npm for npm trust",
|
|
240
|
+
environment.trustNpmVersion
|
|
241
|
+
? `${environment.trustNpm}, resolves to ${environment.trustNpmVersion}`
|
|
242
|
+
: environment.trustNpm,
|
|
243
|
+
`npm 11.10 or newer; npm 12 only on Node.js ${NPM_12_ENGINES}`,
|
|
244
|
+
nodeOk,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
const tfa = environment.twoFactor;
|
|
248
|
+
const tfaDetected = {
|
|
249
|
+
enabled: tfa.mode,
|
|
250
|
+
off: "off",
|
|
251
|
+
unknown: "unknown (sign in to npm first)",
|
|
252
|
+
}[tfa.state];
|
|
253
|
+
items.push(
|
|
254
|
+
prerequisite(
|
|
255
|
+
"npm-2fa",
|
|
256
|
+
"npm two-factor authentication",
|
|
257
|
+
tfaDetected ?? unchecked,
|
|
258
|
+
`enabled at ${NPM_TFA_URL}; npm trust requires it`,
|
|
259
|
+
{ enabled: true, off: false }[tfa.state] ?? null,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (plan.steps.some((step) => step.command?.program === "gh")) {
|
|
264
|
+
const github = environment.github;
|
|
265
|
+
const scopes =
|
|
266
|
+
github.scopes?.length > 0 ? github.scopes.join(", ") : "none listed";
|
|
267
|
+
const detected = {
|
|
268
|
+
"signed-in": `signed in as ${github.login} (scopes: ${scopes})`,
|
|
269
|
+
"signed-out": "not signed in",
|
|
270
|
+
missing: "not installed",
|
|
271
|
+
unknown: "unknown",
|
|
272
|
+
}[github.state];
|
|
273
|
+
let ok = null;
|
|
274
|
+
if (github.state === "signed-in" && github.scopes.length > 0) {
|
|
275
|
+
ok = github.scopes.includes("repo");
|
|
276
|
+
} else if (["signed-out", "missing"].includes(github.state)) {
|
|
277
|
+
ok = false;
|
|
278
|
+
}
|
|
279
|
+
items.push(
|
|
280
|
+
prerequisite(
|
|
281
|
+
"gh",
|
|
282
|
+
"GitHub CLI",
|
|
283
|
+
detected ?? unchecked,
|
|
284
|
+
"signed in with the repo scope, for gh secret and gh run",
|
|
285
|
+
ok,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
items.push(
|
|
290
|
+
prerequisite(
|
|
291
|
+
"browser",
|
|
292
|
+
"Browser",
|
|
293
|
+
browserDescription(browser),
|
|
294
|
+
"signed in to the registry, or ready to sign in",
|
|
295
|
+
null,
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
return items;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function browserDescription({ mode = "default", ...browser }) {
|
|
302
|
+
if (mode === "none") {
|
|
303
|
+
return "none; URLs are printed (--no-browser)";
|
|
304
|
+
}
|
|
305
|
+
if (mode === "automated") {
|
|
306
|
+
return automatedDescription(browser);
|
|
307
|
+
}
|
|
308
|
+
return `your default browser; forms open in ${automatedDescription({ ...browser, profile: undefined })}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Renders prerequisites as indented text lines. */
|
|
312
|
+
export function renderPrerequisites(items) {
|
|
313
|
+
if (items.length === 0) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
return [
|
|
317
|
+
" prerequisites:",
|
|
318
|
+
...items.map(
|
|
319
|
+
(item) =>
|
|
320
|
+
` - ${item.title}: ${item.detected}; needs ${item.required}${item.ok === false ? " [action needed]" : ""}`,
|
|
321
|
+
),
|
|
322
|
+
];
|
|
323
|
+
}
|
package/src/profile.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { exec } from "command-stream";
|
|
6
|
+
|
|
7
|
+
const APPLICATION = "package-registry-manager";
|
|
8
|
+
const PROFILE = "browser-profile";
|
|
9
|
+
const REPOSITORY_DIRECTORY = ".package-registry-manager";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The dedicated automation profile, shared by every repository of the user.
|
|
13
|
+
* It lives in the per-user state directory, never inside a repository, so its
|
|
14
|
+
* registry session cookies cannot be committed.
|
|
15
|
+
*/
|
|
16
|
+
export function defaultBrowserProfile({
|
|
17
|
+
platform = process.platform,
|
|
18
|
+
env = process.env,
|
|
19
|
+
home = os.homedir(),
|
|
20
|
+
} = {}) {
|
|
21
|
+
const join = platform === "win32" ? path.win32.join : path.posix.join;
|
|
22
|
+
if (platform === "win32") {
|
|
23
|
+
const base = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
24
|
+
return join(base, APPLICATION, PROFILE);
|
|
25
|
+
}
|
|
26
|
+
if (platform === "darwin") {
|
|
27
|
+
return join(home, "Library", "Application Support", APPLICATION, PROFILE);
|
|
28
|
+
}
|
|
29
|
+
// The XDG specification ignores relative values.
|
|
30
|
+
const state = path.posix.isAbsolute(env.XDG_STATE_HOME ?? "")
|
|
31
|
+
? env.XDG_STATE_HOME
|
|
32
|
+
: join(home, ".local", "state");
|
|
33
|
+
return join(state, APPLICATION, PROFILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The profile location used before it moved out of the repository. */
|
|
37
|
+
export function legacyBrowserProfile(repository) {
|
|
38
|
+
return path.join(repository, REPOSITORY_DIRECTORY, PROFILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Makes sure Git ignores a browser profile before a browser writes cookies to
|
|
43
|
+
* it. Outside a Git work tree this only creates the directory. Inside one it
|
|
44
|
+
* writes a `.gitignore` containing `*` (into `.package-registry-manager/` for
|
|
45
|
+
* the legacy location, otherwise into the profile itself) and throws when Git
|
|
46
|
+
* still does not ignore the profile or already tracks files in it.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureProfileIgnored(profile, options = {}) {
|
|
49
|
+
await mkdir(profile, { recursive: true, mode: 0o700 });
|
|
50
|
+
const git = (...args) =>
|
|
51
|
+
exec("git", args, {
|
|
52
|
+
cwd: profile,
|
|
53
|
+
capture: true,
|
|
54
|
+
mirror: false,
|
|
55
|
+
stdin: "ignore",
|
|
56
|
+
});
|
|
57
|
+
const worktree = await git("rev-parse", "--show-toplevel");
|
|
58
|
+
if (worktree.code !== 0) {
|
|
59
|
+
if (options.verbose) {
|
|
60
|
+
console.error(`${profile} is not inside a Git work tree`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const top = String(worktree.stdout).trim();
|
|
65
|
+
const parent = path.dirname(profile);
|
|
66
|
+
const guarded =
|
|
67
|
+
path.basename(parent) === REPOSITORY_DIRECTORY ? parent : profile;
|
|
68
|
+
await writeFile(path.join(guarded, ".gitignore"), "*\n");
|
|
69
|
+
// Relative to the profile, so symbolic links in its path do not matter.
|
|
70
|
+
const probe = path.join("Default", "Cookies");
|
|
71
|
+
const ignored = await git("check-ignore", "--quiet", "--", probe);
|
|
72
|
+
const tracked = await git("ls-files", "--", ".");
|
|
73
|
+
if (ignored.code !== 0 || String(tracked.stdout).trim() !== "") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`the browser profile ${profile} is inside the Git work tree ${top} and Git does not ignore it; ` +
|
|
76
|
+
"move it with --browser-profile or ignore it (and untrack its files) before signing in",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (options.verbose) {
|
|
80
|
+
console.error(`${profile} is ignored by Git (${guarded}/.gitignore)`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Protects a browser profile left inside the repository by an older release
|
|
86
|
+
* and suggests deleting it, because it can hold registry session cookies.
|
|
87
|
+
*/
|
|
88
|
+
export async function protectLegacyProfile(repository, profile, options = {}) {
|
|
89
|
+
const legacy = legacyBrowserProfile(repository);
|
|
90
|
+
if (path.resolve(profile) === path.resolve(legacy)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const found = await stat(legacy).then(
|
|
94
|
+
(info) => info.isDirectory(),
|
|
95
|
+
() => false,
|
|
96
|
+
);
|
|
97
|
+
if (found) {
|
|
98
|
+
await ensureProfileIgnored(legacy, options);
|
|
99
|
+
console.error(
|
|
100
|
+
`warning: ${legacy} is no longer used and may hold registry session cookies; ` +
|
|
101
|
+
`delete it (the browser profile is now ${profile})`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|