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
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { openInUserBrowser as openWithSystemOpener } from "browser-commander";
|
|
2
|
+
import { spawn } from "command-stream";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long an opener may run before the tool stops waiting for it: `xdg-open`
|
|
6
|
+
* can wait for a newly started browser to exit.
|
|
7
|
+
*/
|
|
8
|
+
export const OPENER_GRACE_MS = 2_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The platform whose opener Browser Commander runs: `open` on macOS,
|
|
12
|
+
* `explorer.exe` on Windows, and `xdg-open` on Linux and other Unix systems.
|
|
13
|
+
*/
|
|
14
|
+
export function openerPlatform(platform = process.platform) {
|
|
15
|
+
return platform === "darwin" || platform === "win32" ? platform : "linux";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Opens an http(s) URL in the user's own default browser, where they are
|
|
20
|
+
* usually already signed in. Nothing is automated. Browser Commander builds
|
|
21
|
+
* the opener's exact argument vector; `runner` starts it.
|
|
22
|
+
*/
|
|
23
|
+
export async function openInUserBrowser(
|
|
24
|
+
url,
|
|
25
|
+
{ platform = process.platform, runner = detachedRunner() } = {},
|
|
26
|
+
) {
|
|
27
|
+
if (!/^https?:\/\/\S+$/i.test(url)) {
|
|
28
|
+
throw new Error(`refusing to open a non-web URL: ${url}`);
|
|
29
|
+
}
|
|
30
|
+
return openWithSystemOpener(url, {
|
|
31
|
+
platform: openerPlatform(platform),
|
|
32
|
+
runner,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether an opener handed the URL over: `explorer.exe` exits with 1 after it did. */
|
|
37
|
+
export function openerSucceeded(file, code) {
|
|
38
|
+
return code === 0 || (file === "explorer.exe" && code === 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Starts an opener through command-stream without a shell and settles once it
|
|
43
|
+
* exits, or after `graceMs` while it keeps running in the background.
|
|
44
|
+
*/
|
|
45
|
+
export function detachedRunner(graceMs = OPENER_GRACE_MS) {
|
|
46
|
+
return (file, args) =>
|
|
47
|
+
new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(file, args, {
|
|
49
|
+
detached: process.platform !== "win32",
|
|
50
|
+
stdio: "ignore",
|
|
51
|
+
shell: false,
|
|
52
|
+
});
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
child.unref();
|
|
55
|
+
resolve({ code: null });
|
|
56
|
+
}, graceMs);
|
|
57
|
+
child.once("error", (error) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(new Error(`Could not start ${file}: ${error.message}`));
|
|
60
|
+
});
|
|
61
|
+
child.once("exit", (code, signal) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
if (openerSucceeded(file, code)) {
|
|
64
|
+
resolve({ code });
|
|
65
|
+
} else {
|
|
66
|
+
reject(new Error(`${file} exited with code ${code ?? signal}`));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function npmPrefillScript(prefill, submit = false) {
|
|
73
|
+
return `(() => {
|
|
74
|
+
const config = ${JSON.stringify(prefill)};
|
|
75
|
+
const shouldSubmit = ${JSON.stringify(submit)};
|
|
76
|
+
const normalized = value => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
77
|
+
const controls = Array.from(document.querySelectorAll('input, select, textarea'));
|
|
78
|
+
const setValue = (control, value) => {
|
|
79
|
+
const prototype = control instanceof HTMLSelectElement
|
|
80
|
+
? HTMLSelectElement.prototype
|
|
81
|
+
: control instanceof HTMLTextAreaElement
|
|
82
|
+
? HTMLTextAreaElement.prototype
|
|
83
|
+
: HTMLInputElement.prototype;
|
|
84
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
|
85
|
+
if (setter) setter.call(control, value); else control.value = value;
|
|
86
|
+
control.dispatchEvent(new Event('input', { bubbles: true }));
|
|
87
|
+
control.dispatchEvent(new Event('change', { bubbles: true }));
|
|
88
|
+
};
|
|
89
|
+
const textFor = control => {
|
|
90
|
+
const explicit = control.id
|
|
91
|
+
? document.querySelector(\`label[for="\${CSS.escape(control.id)}"]\`)?.textContent
|
|
92
|
+
: '';
|
|
93
|
+
return normalized([explicit, control.getAttribute('aria-label'), control.name,
|
|
94
|
+
control.placeholder, control.closest('label')?.textContent].filter(Boolean).join(' '));
|
|
95
|
+
};
|
|
96
|
+
const values = [
|
|
97
|
+
[['project'], config.project],
|
|
98
|
+
[['organization', 'owner'], config.organization],
|
|
99
|
+
[['repository', 'repo'], config.repository],
|
|
100
|
+
[['workflow'], config.workflow],
|
|
101
|
+
[['environment'], config.environment || ''],
|
|
102
|
+
];
|
|
103
|
+
const filled = [];
|
|
104
|
+
const used = new Set();
|
|
105
|
+
for (const [labels, value] of values) {
|
|
106
|
+
if (!value) continue;
|
|
107
|
+
const control = controls.find(candidate => !used.has(candidate)
|
|
108
|
+
&& labels.some(label => textFor(candidate).includes(label)));
|
|
109
|
+
if (control) { setValue(control, value); used.add(control); filled.push(labels[0]); }
|
|
110
|
+
}
|
|
111
|
+
const provider = Array.from(document.querySelectorAll('button, label, [role="radio"]'))
|
|
112
|
+
.find(element => normalized(element.textContent).includes('github'));
|
|
113
|
+
if (provider && filled.length === 0) provider.click();
|
|
114
|
+
let submitted = false;
|
|
115
|
+
if (shouldSubmit && filled.length >= 3) {
|
|
116
|
+
const button = Array.from(document.querySelectorAll('button, input[type="submit"]'))
|
|
117
|
+
.find(candidate => /add|save|configure|submit/.test(normalized(candidate.textContent || candidate.value))
|
|
118
|
+
&& !candidate.disabled);
|
|
119
|
+
if (button) { button.click(); submitted = true; }
|
|
120
|
+
}
|
|
121
|
+
return { filled, providerSelected: Boolean(provider), submitted };
|
|
122
|
+
})()`;
|
|
123
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
|
|
10
|
+
import { parseBrowserOptions } from "./browser-options.mjs";
|
|
11
|
+
import { inspectRepository } from "./discovery.mjs";
|
|
12
|
+
import { parseRegistry } from "./model.mjs";
|
|
13
|
+
import { buildPlans } from "./plan.mjs";
|
|
14
|
+
import { probeEnvironment, renderPrerequisites } from "./prerequisites.mjs";
|
|
15
|
+
import { probeRegistryState } from "./registry-state.mjs";
|
|
16
|
+
import { BROWSER_MODES, defaultBrowserProfile, executePlan } from "./setup.mjs";
|
|
17
|
+
|
|
18
|
+
const HELP = `Usage: package-registry-manager-js [global options] <command>
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
inspect Discover supported package manifests
|
|
22
|
+
plan [--registry <registry>] Print ordered setup plans
|
|
23
|
+
setup --registry <registry> Bootstrap or attach trusted publishing
|
|
24
|
+
|
|
25
|
+
Global options:
|
|
26
|
+
-h, --help Print this help
|
|
27
|
+
-V, --version Print the version
|
|
28
|
+
--repository <path> Repository to inspect (default: .)
|
|
29
|
+
--format <text|json> Output format (default: text)
|
|
30
|
+
--offline Do not look up packages on registries
|
|
31
|
+
--verbose Print command details and output, and
|
|
32
|
+
list the manifests inspection skipped
|
|
33
|
+
|
|
34
|
+
Plan and setup options:
|
|
35
|
+
--verify-release Also watch the release workflow and confirm
|
|
36
|
+
that the next version has provenance
|
|
37
|
+
--workflow <file> Trusted-publisher workflow file in
|
|
38
|
+
.github/workflows, when detection finds
|
|
39
|
+
several or the wrong one
|
|
40
|
+
--environment <name> GitHub environment of the trusted publisher
|
|
41
|
+
--package <name> Plan only this package, or select it for
|
|
42
|
+
setup when a registry has several
|
|
43
|
+
|
|
44
|
+
Setup options:
|
|
45
|
+
--dry-run Print the flow without running it (default)
|
|
46
|
+
--execute Run the flow and open a visible browser
|
|
47
|
+
--yes Confirm publishing, secret changes, and
|
|
48
|
+
form submission in advance
|
|
49
|
+
--no-browser Print the setup URL instead
|
|
50
|
+
--open-with <app> Open sign-in and approval pages in this
|
|
51
|
+
application instead of the default browser,
|
|
52
|
+
such as "Google Chrome" (macOS) or firefox
|
|
53
|
+
--keep-session Stay signed in to npm after setup; the
|
|
54
|
+
session token stays in npm's user
|
|
55
|
+
configuration until npm logout
|
|
56
|
+
--browser <default|automated> Open sign-in and approval pages in your
|
|
57
|
+
default browser, or in the automated
|
|
58
|
+
profile too (default: default)
|
|
59
|
+
--browser-channel <channel> Installed browser channel: chrome, chromium,
|
|
60
|
+
brave, msedge, msedge-beta, msedge-dev, or
|
|
61
|
+
msedge-canary (default: chrome)
|
|
62
|
+
--browser-executable <path> Installed browser executable to launch
|
|
63
|
+
instead of the channel's
|
|
64
|
+
--browser-profile <path> Dedicated automation profile, used to fill
|
|
65
|
+
forms (default: per-user state directory)
|
|
66
|
+
--browser-import <browser>[:<profile>]
|
|
67
|
+
Copy cookies, history, and other data from
|
|
68
|
+
your chrome, edge, brave, or firefox profile
|
|
69
|
+
into the automated profile first
|
|
70
|
+
--browser-attach <mode> Fill forms in your own browser instead:
|
|
71
|
+
snapshot[:<profile>] launches a temporary
|
|
72
|
+
copy of your profile, extension drives your
|
|
73
|
+
running browser through the Browser
|
|
74
|
+
Commander extension
|
|
75
|
+
--browser-pref <key=value> Browser preference for the automated
|
|
76
|
+
profile, such as intl.accept_languages=en;
|
|
77
|
+
may be repeated
|
|
78
|
+
--browser-restriction <name> Launch restriction or preset, such as
|
|
79
|
+
no-extensions; may be repeated
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export async function main(args = process.argv.slice(2)) {
|
|
83
|
+
const { values, positionals } = parseArgs({
|
|
84
|
+
args,
|
|
85
|
+
allowPositionals: true,
|
|
86
|
+
strict: true,
|
|
87
|
+
options: {
|
|
88
|
+
repository: { type: "string", default: "." },
|
|
89
|
+
format: { type: "string", default: "text" },
|
|
90
|
+
verbose: { type: "boolean", default: false },
|
|
91
|
+
offline: { type: "boolean", default: false },
|
|
92
|
+
"verify-release": { type: "boolean", default: false },
|
|
93
|
+
workflow: { type: "string" },
|
|
94
|
+
environment: { type: "string" },
|
|
95
|
+
"dry-run": { type: "boolean", default: false },
|
|
96
|
+
registry: { type: "string", multiple: true },
|
|
97
|
+
package: { type: "string" },
|
|
98
|
+
execute: { type: "boolean", default: false },
|
|
99
|
+
yes: { type: "boolean", default: false },
|
|
100
|
+
"no-browser": { type: "boolean", default: false },
|
|
101
|
+
"open-with": { type: "string" },
|
|
102
|
+
"keep-session": { type: "boolean", default: false },
|
|
103
|
+
browser: { type: "string", default: "default" },
|
|
104
|
+
"browser-channel": { type: "string", default: "chrome" },
|
|
105
|
+
"browser-profile": { type: "string" },
|
|
106
|
+
"browser-executable": { type: "string" },
|
|
107
|
+
"browser-import": { type: "string" },
|
|
108
|
+
"browser-attach": { type: "string" },
|
|
109
|
+
"browser-pref": { type: "string", multiple: true },
|
|
110
|
+
"browser-restriction": { type: "string", multiple: true },
|
|
111
|
+
help: { type: "boolean", short: "h", default: false },
|
|
112
|
+
version: { type: "boolean", short: "V", default: false },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (values.help) {
|
|
116
|
+
process.stdout.write(HELP);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (values.version) {
|
|
120
|
+
process.stdout.write(`package-registry-manager ${packageVersion()}\n`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (positionals.length !== 1) {
|
|
124
|
+
throw new Error("expected exactly one command: inspect, plan, or setup");
|
|
125
|
+
}
|
|
126
|
+
if (!["text", "json"].includes(values.format)) {
|
|
127
|
+
throw new Error("--format must be 'text' or 'json'");
|
|
128
|
+
}
|
|
129
|
+
if (!BROWSER_MODES.includes(values.browser)) {
|
|
130
|
+
throw new Error("--browser must be 'default' or 'automated'");
|
|
131
|
+
}
|
|
132
|
+
const browserOptions = parseBrowserOptions({
|
|
133
|
+
channel: values["browser-channel"],
|
|
134
|
+
executable: values["browser-executable"],
|
|
135
|
+
importFrom: values["browser-import"],
|
|
136
|
+
attach: values["browser-attach"],
|
|
137
|
+
preferences: values["browser-pref"],
|
|
138
|
+
restrictions: values["browser-restriction"],
|
|
139
|
+
profileGiven: values["browser-profile"] !== undefined,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const repository = path.resolve(values.repository);
|
|
143
|
+
const discovered = await inspectRepository(repository, {
|
|
144
|
+
includeSkipped: values.verbose,
|
|
145
|
+
});
|
|
146
|
+
const inspection = values.offline
|
|
147
|
+
? discovered
|
|
148
|
+
: await probeRegistryState(discovered, { verbose: values.verbose });
|
|
149
|
+
const command = positionals[0];
|
|
150
|
+
if (command === "inspect") {
|
|
151
|
+
outputInspection(inspection, values.format);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const registries = (values.registry ?? []).map(parseRegistry);
|
|
156
|
+
if (!["plan", "setup"].includes(command)) {
|
|
157
|
+
throw new Error(`unknown command '${command}'`);
|
|
158
|
+
}
|
|
159
|
+
const environment = await probeEnvironment({
|
|
160
|
+
offline: values.offline,
|
|
161
|
+
verbose: values.verbose,
|
|
162
|
+
npm: inspection.packages.some(
|
|
163
|
+
(item) =>
|
|
164
|
+
item.registry === "npm" &&
|
|
165
|
+
item.publishable &&
|
|
166
|
+
(registries.length === 0 || registries.includes("npm")),
|
|
167
|
+
),
|
|
168
|
+
});
|
|
169
|
+
const planOptions = {
|
|
170
|
+
verifyRelease: values["verify-release"],
|
|
171
|
+
workflow: await workflowOverride(repository, values.workflow),
|
|
172
|
+
publisherEnvironment: nonEmpty(values.environment, "--environment"),
|
|
173
|
+
environment,
|
|
174
|
+
browser: browserSummary(command, values, browserOptions),
|
|
175
|
+
};
|
|
176
|
+
if (command === "plan") {
|
|
177
|
+
const plans = filterPlans(
|
|
178
|
+
buildPlans(inspection, registries, planOptions),
|
|
179
|
+
values.package,
|
|
180
|
+
);
|
|
181
|
+
outputPlans(plans, values.format);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (registries.length !== 1) {
|
|
185
|
+
throw new Error("setup requires exactly one --registry <registry>");
|
|
186
|
+
}
|
|
187
|
+
if (values["dry-run"] && values.execute) {
|
|
188
|
+
throw new Error("--dry-run and --execute are mutually exclusive");
|
|
189
|
+
}
|
|
190
|
+
if (values.yes && !values.execute) {
|
|
191
|
+
throw new Error("--yes requires --execute");
|
|
192
|
+
}
|
|
193
|
+
if (values["no-browser"] && !values.execute) {
|
|
194
|
+
throw new Error("--no-browser requires --execute");
|
|
195
|
+
}
|
|
196
|
+
if (values["open-with"] !== undefined && values["no-browser"]) {
|
|
197
|
+
throw new Error("--open-with and --no-browser are mutually exclusive");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const plans = buildPlans(inspection, registries, planOptions);
|
|
201
|
+
const plan = selectPlan(plans, values.package);
|
|
202
|
+
outputPlans([plan], values.format);
|
|
203
|
+
await executePlan(plan, {
|
|
204
|
+
repository,
|
|
205
|
+
execute: values.execute,
|
|
206
|
+
yes: values.yes,
|
|
207
|
+
noBrowser: values["no-browser"],
|
|
208
|
+
openWith: nonEmpty(values["open-with"], "--open-with"),
|
|
209
|
+
keepSession: values["keep-session"],
|
|
210
|
+
browser: values.browser,
|
|
211
|
+
browserOptions,
|
|
212
|
+
browserProfile: path.resolve(
|
|
213
|
+
values["browser-profile"] ?? defaultBrowserProfile(),
|
|
214
|
+
),
|
|
215
|
+
verbose: values.verbose,
|
|
216
|
+
verifyRelease: values["verify-release"],
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function workflowOverride(repository, workflow) {
|
|
221
|
+
if (workflow === undefined) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!/^[\w.-]+\.ya?ml$/.test(workflow)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"--workflow must be a workflow file name in .github/workflows, such as release.yml",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const file = path.join(repository, ".github/workflows", workflow);
|
|
230
|
+
const found = await stat(file).then(
|
|
231
|
+
(metadata) => metadata.isFile(),
|
|
232
|
+
() => false,
|
|
233
|
+
);
|
|
234
|
+
if (!found) {
|
|
235
|
+
throw new Error(`--workflow: .github/workflows/${workflow} does not exist`);
|
|
236
|
+
}
|
|
237
|
+
return workflow;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function nonEmpty(value, option) {
|
|
241
|
+
if (value !== undefined && !value.trim()) {
|
|
242
|
+
throw new Error(`${option} must not be empty`);
|
|
243
|
+
}
|
|
244
|
+
return value?.trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function browserSummary(command, values, browserOptions) {
|
|
248
|
+
if (command === "setup" && values["no-browser"]) {
|
|
249
|
+
return { mode: "none" };
|
|
250
|
+
}
|
|
251
|
+
if (command !== "setup") {
|
|
252
|
+
return { mode: "default", channel: values["browser-channel"] };
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
mode: values.browser,
|
|
256
|
+
channel: browserOptions.channel,
|
|
257
|
+
profile:
|
|
258
|
+
values.browser === "automated" && !browserOptions.attach
|
|
259
|
+
? path.resolve(values["browser-profile"] ?? defaultBrowserProfile())
|
|
260
|
+
: undefined,
|
|
261
|
+
import: browserOptions.import ?? undefined,
|
|
262
|
+
attach: browserOptions.attach ?? undefined,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Keeps the plans of `packageName`, or every plan without one. */
|
|
267
|
+
function filterPlans(plans, packageName) {
|
|
268
|
+
if (plans.length === 0) {
|
|
269
|
+
throw new Error("no matching package manifests were found");
|
|
270
|
+
}
|
|
271
|
+
if (!packageName) {
|
|
272
|
+
return plans;
|
|
273
|
+
}
|
|
274
|
+
const named = plans.filter((plan) => plan.package.name === packageName);
|
|
275
|
+
if (named.length === 0) {
|
|
276
|
+
throw new Error(`package '${packageName}' was not found`);
|
|
277
|
+
}
|
|
278
|
+
return named;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function selectPlan(plans, packageName) {
|
|
282
|
+
if (packageName) {
|
|
283
|
+
const plan = plans.find(
|
|
284
|
+
(candidate) => candidate.package.name === packageName,
|
|
285
|
+
);
|
|
286
|
+
if (!plan) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`package '${packageName}' was not found for this registry`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return plan;
|
|
292
|
+
}
|
|
293
|
+
if (plans.length === 0) {
|
|
294
|
+
throw new Error("no matching package manifests were found");
|
|
295
|
+
}
|
|
296
|
+
const publishable = plans.filter((plan) => plan.package.publishable);
|
|
297
|
+
const candidates = publishable.length > 0 ? publishable : plans;
|
|
298
|
+
if (candidates.length > 1) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
"multiple packages use this registry; select one with --package <name>",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return candidates[0];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function outputInspection(inspection, format) {
|
|
307
|
+
if (format === "json") {
|
|
308
|
+
process.stdout.write(`${JSON.stringify(inspection, null, 2)}\n`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
process.stdout.write(`Repository: ${inspection.repository.root}\n`);
|
|
312
|
+
if (inspection.packages.length === 0) {
|
|
313
|
+
process.stdout.write("No supported package manifests found.\n");
|
|
314
|
+
}
|
|
315
|
+
for (const packageInfo of inspection.packages) {
|
|
316
|
+
const details = [
|
|
317
|
+
packageInfo.manifest,
|
|
318
|
+
packageInfo.publishable ? "publishable" : "not publishable",
|
|
319
|
+
];
|
|
320
|
+
if (packageInfo.exists_on_registry !== undefined) {
|
|
321
|
+
details.push(
|
|
322
|
+
packageInfo.exists_on_registry ? "published" : "not published yet",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (packageInfo.trusted_publishing) {
|
|
326
|
+
details.push("trusted publishing");
|
|
327
|
+
}
|
|
328
|
+
process.stdout.write(
|
|
329
|
+
`- ${packageInfo.registry}: ${packageInfo.name} (${details.join(", ")})\n`,
|
|
330
|
+
);
|
|
331
|
+
if (packageInfo.workflow && packageInfo.publishable) {
|
|
332
|
+
process.stdout.write(` workflow: ${publisherSummary(packageInfo)}\n`);
|
|
333
|
+
}
|
|
334
|
+
for (const warning of packageInfo.warnings ?? []) {
|
|
335
|
+
process.stdout.write(` warning: ${warning}\n`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const item of inspection.skipped ?? []) {
|
|
339
|
+
process.stdout.write(`skipped ${item.manifest}: ${item.reason}\n`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function publisherSummary({ workflow, workflow_jobs, environment }) {
|
|
344
|
+
const details = [];
|
|
345
|
+
if (workflow_jobs?.length) {
|
|
346
|
+
details.push(
|
|
347
|
+
`${workflow_jobs.length > 1 ? "jobs" : "job"} ${workflow_jobs.join(", ")}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (environment) {
|
|
351
|
+
details.push(`environment ${environment}`);
|
|
352
|
+
}
|
|
353
|
+
return details.length > 0 ? `${workflow} (${details.join("; ")})` : workflow;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function outputPlans(plans, format) {
|
|
357
|
+
if (format === "json") {
|
|
358
|
+
process.stdout.write(`${JSON.stringify(plans, null, 2)}\n`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
for (const plan of plans) {
|
|
362
|
+
const mode = plan.mode ? ` (${plan.mode})` : "";
|
|
363
|
+
process.stdout.write(`${plan.registry}: ${plan.package.name}${mode}\n`);
|
|
364
|
+
if (plan.mode === "complete") {
|
|
365
|
+
process.stdout.write(" trusted publishing is already in use\n");
|
|
366
|
+
}
|
|
367
|
+
if (plan.skipped_reason) {
|
|
368
|
+
process.stdout.write(` skipped: ${plan.skipped_reason}\n`);
|
|
369
|
+
}
|
|
370
|
+
if (plan.trusted_publisher) {
|
|
371
|
+
process.stdout.write(
|
|
372
|
+
` trusted publisher: ${publisherSummary({
|
|
373
|
+
...plan.package,
|
|
374
|
+
workflow: plan.trusted_publisher.workflow,
|
|
375
|
+
environment: plan.trusted_publisher.environment,
|
|
376
|
+
workflow_jobs:
|
|
377
|
+
plan.trusted_publisher.workflow === plan.package.workflow
|
|
378
|
+
? plan.package.workflow_jobs
|
|
379
|
+
: undefined,
|
|
380
|
+
})}\n`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
for (const warning of plan.package.warnings ?? []) {
|
|
384
|
+
process.stdout.write(` warning: ${warning}\n`);
|
|
385
|
+
}
|
|
386
|
+
for (const line of renderPrerequisites(plan.prerequisites ?? [])) {
|
|
387
|
+
process.stdout.write(`${line}\n`);
|
|
388
|
+
}
|
|
389
|
+
plan.steps.forEach((step, index) => {
|
|
390
|
+
const when = step.when ? ` [when ${step.when}]` : "";
|
|
391
|
+
process.stdout.write(` ${index + 1}. ${step.title}${when}\n`);
|
|
392
|
+
if (step.command) {
|
|
393
|
+
const cwd = step.cwd && step.cwd !== "." ? `(in ${step.cwd}) ` : "";
|
|
394
|
+
process.stdout.write(
|
|
395
|
+
` $ ${cwd}${step.command.program} ${step.command.args.join(" ")}\n`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (step.url) {
|
|
399
|
+
process.stdout.write(` ${step.url}\n`);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function packageVersion() {
|
|
406
|
+
const manifest = new URL("../package.json", import.meta.url);
|
|
407
|
+
return JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Whether this module is the program node started. npm, npx, and global
|
|
412
|
+
* installs start bins through a symlink in node_modules/.bin, while
|
|
413
|
+
* import.meta.url names the real file, so both sides are resolved first.
|
|
414
|
+
*/
|
|
415
|
+
export function isDirectExecution(moduleUrl, entryPath) {
|
|
416
|
+
if (!entryPath) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPath);
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (isDirectExecution(import.meta.url, process.argv[1])) {
|
|
427
|
+
main().catch((error) => {
|
|
428
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { grantsPackagesWrite, publishingWorkflow } from "./workflows.mjs";
|
|
2
|
+
|
|
3
|
+
export const CONTAINER_FILES = new Set(["Dockerfile", "Containerfile"]);
|
|
4
|
+
|
|
5
|
+
const HOSTS = new Map([
|
|
6
|
+
["docker-hub", "docker\\.io"],
|
|
7
|
+
["ghcr", "ghcr\\.io"],
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Describes Docker Hub and GHCR images built from a Dockerfile and pushed by a
|
|
12
|
+
* GitHub Actions workflow.
|
|
13
|
+
*/
|
|
14
|
+
export function containerPackages(dockerfiles, workflows, coordinates) {
|
|
15
|
+
if (dockerfiles.length === 0) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const manifest = [...dockerfiles].sort(
|
|
19
|
+
(left, right) =>
|
|
20
|
+
left.split("/").length - right.split("/").length ||
|
|
21
|
+
left.localeCompare(right),
|
|
22
|
+
)[0];
|
|
23
|
+
const packages = [];
|
|
24
|
+
for (const [registry, host] of HOSTS) {
|
|
25
|
+
const workflow = publishingWorkflow(workflows, registry);
|
|
26
|
+
if (!workflow) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const name =
|
|
30
|
+
literalImage(workflow.contents, host) ?? defaultImage(coordinates);
|
|
31
|
+
const item = {
|
|
32
|
+
registry,
|
|
33
|
+
name: name ?? "unknown-image",
|
|
34
|
+
version: null,
|
|
35
|
+
manifest,
|
|
36
|
+
publishable: Boolean(name),
|
|
37
|
+
workflow: workflow.name,
|
|
38
|
+
};
|
|
39
|
+
if (!name) {
|
|
40
|
+
item.problems = [
|
|
41
|
+
"the image name could not be derived from the workflow or a GitHub remote",
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
if (registry === "ghcr" && !grantsPackagesWrite(workflow.contents)) {
|
|
45
|
+
item.warnings = [
|
|
46
|
+
`${workflow.name} does not grant packages: write, so GITHUB_TOKEN cannot push to ghcr.io`,
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
packages.push(item);
|
|
50
|
+
}
|
|
51
|
+
return packages;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function literalImage(contents, host) {
|
|
55
|
+
const match = new RegExp(
|
|
56
|
+
`\\b${host}/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*[a-z0-9])`,
|
|
57
|
+
"i",
|
|
58
|
+
).exec(contents);
|
|
59
|
+
return match ? match[1].toLowerCase() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultImage({ github_owner, github_repository }) {
|
|
63
|
+
return github_owner && github_repository
|
|
64
|
+
? `${github_owner}/${github_repository}`.toLowerCase()
|
|
65
|
+
: null;
|
|
66
|
+
}
|