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.
@@ -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
+ }