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 ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to the JavaScript package are documented here. The package
4
+ currently shares its release version with the Rust implementation.
5
+
6
+ ## [Unreleased]
7
+
8
+ ### Added
9
+
10
+ - `setup --registry npm --execute` takes a never-published package to OIDC
11
+ trusted publishing in one run: web login, a confirmed first publish from a
12
+ temporary worktree of the default branch, `npm trust github`, and cleanup of
13
+ a leftover `NPM_TOKEN` secret. It never reads, creates, or asks for an npm
14
+ token (#3).
15
+ - `inspect` and `plan` look packages up on npm, crates.io, PyPI, and Docker Hub
16
+ and plan a `bootstrap`, `attach`, or `complete` mode. `--offline` skips the
17
+ lookups, `--dry-run` prints the whole flow, and `--verify-release` watches the
18
+ next release for provenance (#3).
19
+ - PyPI bootstraps through a pending publisher and a first OIDC publish from CI;
20
+ crates.io publishes once with a revocable token and then opens the trusted
21
+ publisher settings (#3).
22
+ - Docker Hub and GHCR images are detected from a `Dockerfile` and the release
23
+ workflow, with plans for the repository, a scoped token, `DOCKERHUB_*`
24
+ variables and secret, `packages: write`, and package linking (#5).
25
+ - `setup --browser default|automated` chooses where sign-in and approval URLs
26
+ open; `default`, the default, uses the user's default browser (#9).
27
+
28
+ ### Changed
29
+
30
+ - npm web-auth URLs (`npm login`, `npm publish` 2FA, `npm trust`) open in the
31
+ user's default browser (`open`, `xdg-open`, or the Windows URL handler),
32
+ where they are usually already signed in. The automated browser profile is
33
+ only used to fill the trusted-publisher form (#9).
34
+ - The automation browser profile now defaults to a per-user state directory
35
+ outside any repository (`~/Library/Application Support` on macOS,
36
+ `$XDG_STATE_HOME` or `~/.local/state` on Linux, `%LOCALAPPDATA%` on Windows,
37
+ each followed by `package-registry-manager/browser-profile`) (#8).
38
+
39
+ ### Fixed
40
+
41
+ - A browser profile inside a Git work tree gets a `.gitignore` containing `*`
42
+ before the browser starts, and setup refuses to continue if Git still does
43
+ not ignore it or already tracks files in it. A profile left in
44
+ `.package-registry-manager/` by an earlier release is protected the same way,
45
+ with a warning to delete it (#8).
46
+ - When a web login is not completed and npm falls back to its legacy
47
+ `Username:` prompt, setup stops npm and says to re-run for a fresh login link
48
+ instead of failing with `npm exited with status 1` (#9).
49
+
50
+ - `plan` no longer emits setup steps or a trusted publisher for unpublishable
51
+ packages; they appear with `"steps": []` and a `skipped_reason`, and `setup`
52
+ picks the only publishable package when a registry also has private ones
53
+ (#4).
54
+
55
+ ## [0.19.35] - 2026-09-21
56
+
57
+ ### Added
58
+
59
+ - Initial JavaScript implementation of repository inspection and registry
60
+ setup planning.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # package-registry-manager (JavaScript)
2
+
3
+ Node.js implementation of
4
+ [package-registry-manager](https://github.com/link-foundation/package-registry-manager).
5
+ It discovers package manifests, emits registry setup plans, validates packages
6
+ with exact argument vectors, and guides authenticated setup in the default
7
+ browser, filling forms in a dedicated visible browser profile.
8
+
9
+ ```bash
10
+ npx package-registry-manager inspect --repository /path/to/repository
11
+ npx package-registry-manager plan --repository /path/to/repository --format json
12
+ npx package-registry-manager setup --repository /path/to/repository --registry npm
13
+ ```
14
+
15
+ The package installs the command as both `package-registry-manager` and
16
+ `package-registry-manager-js`; use the second name when the Rust command of the
17
+ same name is also on your `PATH`.
18
+
19
+ Setup is a dry run unless `--execute` is present, and it never publishes an
20
+ artifact. See the repository README for all registry walkthroughs and safety
21
+ details.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "package-registry-manager",
3
+ "version": "0.21.0",
4
+ "description": "Inspect repositories and guide secure package-registry setup",
5
+ "type": "module",
6
+ "bin": {
7
+ "package-registry-manager": "src/cli.mjs",
8
+ "package-registry-manager-js": "src/cli.mjs"
9
+ },
10
+ "exports": "./src/index.mjs",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/link-foundation/package-registry-manager.git",
17
+ "directory": "js"
18
+ },
19
+ "license": "Unlicense",
20
+ "files": [
21
+ "src/",
22
+ "CHANGELOG.md",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
30
+ "scripts": {
31
+ "syntax": "node --check src/cli.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
32
+ "lint": "eslint src test scripts --max-warnings 0",
33
+ "lint:fix": "eslint src test scripts --fix --max-warnings 0",
34
+ "format": "prettier --write .",
35
+ "format:check": "prettier --check .",
36
+ "check:duplication": "jscpd src",
37
+ "check": "npm run syntax && npm run lint && npm run format:check && npm run check:duplication",
38
+ "test": "node --test --test-timeout=30000",
39
+ "pack:check": "npm pack --dry-run"
40
+ },
41
+ "dependencies": {
42
+ "browser-commander": "^0.21.2",
43
+ "command-stream": "^1.3.0",
44
+ "playwright": "^1.63.0"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^10.0.1",
48
+ "eslint": "^10.11.0",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "jscpd": "^5.4.0",
51
+ "prettier": "^3.9.9"
52
+ }
53
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * npm's browser sign-in and approval links: how long they stay valid, how the
3
+ * tool recognizes an expired one, and how often it asks npm for a fresh one.
4
+ */
5
+
6
+ /** How long npm keeps a web login or approval session open. */
7
+ export const APPROVAL_WINDOW_MS = 5 * 60_000;
8
+ /** How many links the tool requests before it gives up on one step. */
9
+ export const APPROVAL_ATTEMPTS = 3;
10
+ /** npm's error once its approval session expired while it polled for the result. */
11
+ export const EXPIRED_APPROVAL = /Invalid response from web login endpoint/i;
12
+
13
+ /** Shown with every npm approval link. */
14
+ export const TWO_FACTOR_HINT =
15
+ "If npm's approval page offers to skip two-factor checks for the next 5 minutes, choose it so the approvals that follow need no new confirmation.";
16
+
17
+ const pad = (number) => String(number).padStart(2, "0");
18
+
19
+ /**
20
+ * The deadline printed with a sign-in or approval link, such as
21
+ * "Sign in within about 5 minutes (until 19:05).".
22
+ */
23
+ export function approvalDeadline(kind, now = new Date()) {
24
+ const until = new Date(now.getTime() + APPROVAL_WINDOW_MS);
25
+ const action = kind === "login" ? "Sign in" : "Approve";
26
+ return `${action} within about 5 minutes (until ${pad(until.getHours())}:${pad(until.getMinutes())}).`;
27
+ }
28
+
29
+ /**
30
+ * Why an npm run ended on an expired link, or undefined when it did not: a
31
+ * web login falls back to npm's legacy `Username:` prompt, and a publish or
32
+ * trust approval fails with npm's web-login error.
33
+ */
34
+ export function expiredApproval(result) {
35
+ if (result.legacyLogin) {
36
+ return "npm fell back to its legacy username prompt";
37
+ }
38
+ if (result.code !== 0 && result.approvalExpired) {
39
+ return "npm's approval session ended";
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Runs `attempt()` until it does not end on an expired link, up to
46
+ * `attempts` times, printing why each fresh link is requested.
47
+ */
48
+ export async function withFreshLinks(
49
+ attempt,
50
+ { attempts = APPROVAL_ATTEMPTS, log = console.log } = {},
51
+ ) {
52
+ for (let number = 1; ; number += 1) {
53
+ const result = await attempt();
54
+ const reason = expiredApproval(result);
55
+ if (!reason) {
56
+ return result;
57
+ }
58
+ if (number >= attempts) {
59
+ throw new Error(
60
+ `the browser link expired ${attempts} times (${reason}); re-run the command when you are ready to approve within 5 minutes`,
61
+ );
62
+ }
63
+ log(
64
+ `\nThe link expired before it was approved (${reason}); requesting a fresh one (attempt ${number + 1} of ${attempts}).`,
65
+ );
66
+ }
67
+ }
68
+
69
+ /** The closing note once a package publishes through trusted publishing. */
70
+ export function oidcReleaseNote(workflow) {
71
+ return `Future releases publish from ${workflow} through trusted publishing; no login is needed.`;
72
+ }
@@ -0,0 +1,149 @@
1
+ import { spawn } from "command-stream";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { EXPIRED_APPROVAL } from "./approvals.mjs";
7
+
8
+ /**
9
+ * Preloaded into npm so it treats piped stdout as a terminal. npm only offers
10
+ * web authentication (`Authenticate your account at:`) on a TTY; the tool
11
+ * pipes stdout to find those URLs and open them in a browser.
12
+ */
13
+ export const TTY_SHIM = `for (const stream of [process.stdin, process.stdout]) {
14
+ if (!stream.isTTY) {
15
+ Object.defineProperty(stream, "isTTY", { value: true, configurable: true });
16
+ }
17
+ }
18
+ `;
19
+
20
+ const PROMPTS = [
21
+ [/^Login at:?$/i, "login"],
22
+ [/^Authenticate your account at:?$/i, "approve"],
23
+ ];
24
+ const INLINE = /(Login at|Authenticate your account at):?\s+(https?:\/\/\S+)/i;
25
+ const linkKind = (label) => (/^login/i.test(label) ? "login" : "approve");
26
+ // npm falls back to this prompt when a web login is not completed in time.
27
+ const LEGACY_LOGIN = /^Username:/i;
28
+ /** Terminal color and cursor escape sequences. */
29
+ // eslint-disable-next-line no-control-regex
30
+ export const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
31
+
32
+ const clean = (raw) => raw.replace(ANSI, "").trim();
33
+
34
+ /**
35
+ * Returns a line scanner that reports web-authentication URLs printed by npm,
36
+ * with their kind (`login` or `approve`), and, once, npm's legacy `Username:`
37
+ * prompt, which is printed without a trailing newline.
38
+ */
39
+ export function authUrlScanner(onUrl, onLegacyLogin) {
40
+ let pending = "";
41
+ let expectUrl = null;
42
+ let legacy = false;
43
+ const seen = new Set();
44
+ const detectLegacy = (line) => {
45
+ if (onLegacyLogin && !legacy && LEGACY_LOGIN.test(line)) {
46
+ legacy = true;
47
+ onLegacyLogin();
48
+ }
49
+ };
50
+ const report = (url, kind) => {
51
+ if (!seen.has(url)) {
52
+ seen.add(url);
53
+ onUrl(url, kind);
54
+ }
55
+ };
56
+ const scanLine = (raw) => {
57
+ const line = clean(raw);
58
+ detectLegacy(line);
59
+ const inline = INLINE.exec(line);
60
+ const prompt = PROMPTS.find(([pattern]) => pattern.test(line));
61
+ if (inline) {
62
+ report(inline[2], linkKind(inline[1]));
63
+ expectUrl = null;
64
+ } else if (prompt) {
65
+ expectUrl = prompt[1];
66
+ } else if (expectUrl && /^https?:\/\/\S+$/.test(line)) {
67
+ report(line, expectUrl);
68
+ expectUrl = null;
69
+ } else if (line) {
70
+ expectUrl = null;
71
+ }
72
+ };
73
+ return (chunk) => {
74
+ pending += chunk;
75
+ const lines = pending.split(/\r?\n/);
76
+ pending = lines.pop();
77
+ lines.forEach(scanLine);
78
+ detectLegacy(clean(pending));
79
+ };
80
+ }
81
+
82
+ /** Appends a `--require` of the TTY shim to existing NODE_OPTIONS. */
83
+ export function nodeOptionsWithShim(existing, shimPath) {
84
+ const quoted = shimPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
85
+ return [existing, `--require "${quoted}"`].filter(Boolean).join(" ");
86
+ }
87
+
88
+ /** Writes the TTY shim to a private temporary directory. */
89
+ export async function writeTtyShim() {
90
+ const directory = await mkdtemp(path.join(os.tmpdir(), "prm-shim-"));
91
+ const shim = path.join(directory, "tty-shim.cjs");
92
+ await writeFile(shim, TTY_SHIM, { mode: 0o600 });
93
+ return { directory, shim };
94
+ }
95
+
96
+ /**
97
+ * Runs an exact argument vector with the terminal's stdin and stderr, while
98
+ * mirroring and capturing stdout. stdin stays a real terminal so masked
99
+ * prompts (cargo login, gh secret set) keep working. With
100
+ * `options.stopOnLegacyLogin` the command is stopped at npm's legacy
101
+ * `Username:` prompt and the result has `legacyLogin: true`. With
102
+ * `options.watchStderr` stderr is mirrored too, and the result has
103
+ * `approvalExpired: true` once npm reports an expired approval session.
104
+ */
105
+ export function runInteractive(command, options) {
106
+ return new Promise((resolve, reject) => {
107
+ const child = spawn(command.program, command.args, {
108
+ cwd: options.cwd,
109
+ env: options.env ?? process.env,
110
+ stdio: ["inherit", "pipe", options.watchStderr ? "pipe" : "inherit"],
111
+ shell: false,
112
+ });
113
+ let legacyLogin = false;
114
+ const stop = options.stopOnLegacyLogin
115
+ ? () => {
116
+ legacyLogin = true;
117
+ child.kill();
118
+ }
119
+ : undefined;
120
+ const scan =
121
+ options.onUrl || stop
122
+ ? authUrlScanner(options.onUrl ?? (() => {}), stop)
123
+ : null;
124
+ let stdout = "";
125
+ child.stdout.setEncoding("utf8");
126
+ child.stdout.on("data", (chunk) => {
127
+ stdout += chunk;
128
+ if (options.mirror !== false) {
129
+ process.stdout.write(chunk);
130
+ }
131
+ scan?.(chunk);
132
+ });
133
+ let approvalExpired = false;
134
+ let errors = "";
135
+ if (options.watchStderr) {
136
+ child.stderr.setEncoding("utf8");
137
+ child.stderr.on("data", (chunk) => {
138
+ process.stderr.write(chunk);
139
+ errors = `${errors}${chunk}`.slice(-4096);
140
+ approvalExpired ||= EXPIRED_APPROVAL.test(errors.replace(ANSI, ""));
141
+ });
142
+ }
143
+ child.on("error", reject);
144
+ child.on("close", (code) => {
145
+ scan?.("\n");
146
+ resolve({ code: code ?? 1, stdout, legacyLogin, approvalExpired });
147
+ });
148
+ });
149
+ }
@@ -0,0 +1,124 @@
1
+ import { cp } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ attachViaExtension,
6
+ EXTENSION_DIRECTORY,
7
+ launchRealBrowser,
8
+ } from "browser-commander";
9
+
10
+ import { snapshotBrowser } from "./browser-options.mjs";
11
+ import { defaultBrowserProfile, ensureProfileIgnored } from "./profile.mjs";
12
+
13
+ /** How long `--browser-attach extension` waits for the extension to connect. */
14
+ export const EXTENSION_TIMEOUT_MS = 300_000;
15
+
16
+ /** Where the companion extension is written for Chrome's "Load unpacked". */
17
+ export function relayExtensionDirectory() {
18
+ return path.join(path.dirname(defaultBrowserProfile()), "relay-extension");
19
+ }
20
+
21
+ /**
22
+ * The `launchRealBrowser` options for the automated browser: the dedicated
23
+ * profile by default, optionally seeded from a real profile, or a temporary
24
+ * snapshot of the user's own profile with `--browser-attach snapshot`.
25
+ */
26
+ export function launchOptions(browser, profile, verbose = false) {
27
+ const options = {
28
+ engine: "playwright",
29
+ channel: browser.channel,
30
+ headless: false,
31
+ verbose,
32
+ };
33
+ if (browser.executable) {
34
+ options.executablePath = browser.executable;
35
+ }
36
+ if (browser.restrictions.length > 0) {
37
+ options.restrictions = browser.restrictions;
38
+ }
39
+ if (Object.keys(browser.preferences).length > 0) {
40
+ options.preferences = browser.preferences;
41
+ }
42
+ if (browser.attach?.mode === "snapshot") {
43
+ options.attach = {
44
+ mode: "snapshot",
45
+ browser: snapshotBrowser(browser.channel),
46
+ profile: browser.attach.profile ?? "Default",
47
+ };
48
+ return options;
49
+ }
50
+ options.userDataDir = profile;
51
+ if (browser.import) {
52
+ options.migrateFrom = {
53
+ browser: browser.import.browser,
54
+ ...(browser.import.profile ? { profile: browser.import.profile } : {}),
55
+ };
56
+ }
57
+ return options;
58
+ }
59
+
60
+ /** The instructions printed while waiting for the companion extension. */
61
+ export function extensionInstructions(directory) {
62
+ return [
63
+ `Waiting up to ${EXTENSION_TIMEOUT_MS / 60_000} minutes for the Browser Commander extension in your own browser.`,
64
+ 'If it is not installed, open chrome://extensions, turn on Developer mode, click "Load unpacked", and choose:',
65
+ ` ${directory}`,
66
+ ].join("\n");
67
+ }
68
+
69
+ /**
70
+ * Connects the browser that fills forms and returns a page with `goto`,
71
+ * `evaluate`, and `close`.
72
+ */
73
+ export async function connectAutomation({ browser, profile, verbose = false }) {
74
+ if (browser.attach?.mode === "extension") {
75
+ return connectExtension();
76
+ }
77
+ if (!browser.attach) {
78
+ await ensureProfileIgnored(profile, { verbose });
79
+ }
80
+ const connection = await launchRealBrowser(
81
+ launchOptions(browser, profile, verbose),
82
+ );
83
+ return {
84
+ goto: (url) => connection.page.goto(url),
85
+ evaluate: (script) => connection.page.evaluate(script),
86
+ close: () => connection.browser.close(),
87
+ };
88
+ }
89
+
90
+ async function connectExtension() {
91
+ const directory = relayExtensionDirectory();
92
+ await cp(EXTENSION_DIRECTORY, directory, { recursive: true });
93
+ const relay = await attachViaExtension({
94
+ timeoutMs: EXTENSION_TIMEOUT_MS,
95
+ onListening: () => console.log(extensionInstructions(directory)),
96
+ });
97
+ let session = null;
98
+ return {
99
+ async goto(url) {
100
+ if (session) {
101
+ await session.send("Page.navigate", { url });
102
+ } else {
103
+ const { tabId } = await relay.newTab(url);
104
+ session = await relay.session(tabId);
105
+ }
106
+ },
107
+ async evaluate(expression) {
108
+ const { result, exceptionDetails } = await session.send(
109
+ "Runtime.evaluate",
110
+ { expression, returnByValue: true, awaitPromise: true },
111
+ );
112
+ if (exceptionDetails) {
113
+ throw new Error(
114
+ `the page script failed: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`,
115
+ );
116
+ }
117
+ return result.value;
118
+ },
119
+ async close() {
120
+ await session?.detach().catch(() => {});
121
+ await relay.close();
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,175 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ LAUNCH_RESTRICTION_PRESETS,
5
+ LAUNCH_RESTRICTIONS,
6
+ } from "browser-commander";
7
+
8
+ /** Browsers whose profile `--browser-import` can migrate. */
9
+ export const IMPORT_BROWSERS = ["chrome", "edge", "brave", "firefox"];
10
+ /** How `--browser-attach` reaches the user's own browser. */
11
+ export const ATTACH_MODES = ["snapshot", "extension"];
12
+
13
+ /**
14
+ * Every launch restriction and preset `--browser-restriction` accepts, from
15
+ * Browser Commander's shared catalogue.
16
+ */
17
+ export function restrictionNames() {
18
+ return [
19
+ ...LAUNCH_RESTRICTIONS.map((restriction) => restriction.id),
20
+ ...Object.keys(LAUNCH_RESTRICTION_PRESETS),
21
+ ];
22
+ }
23
+
24
+ /**
25
+ * Validates the browser options of the automated profile. Nothing changes by
26
+ * default: a fresh dedicated profile fills forms. `--browser-import` migrates
27
+ * a real profile into it, and `--browser-attach` uses the user's own browser
28
+ * instead, as a temporary snapshot or through the companion extension.
29
+ */
30
+ export function parseBrowserOptions({
31
+ channel = "chrome",
32
+ executable,
33
+ importFrom,
34
+ attach,
35
+ preferences = [],
36
+ restrictions = [],
37
+ profileGiven = false,
38
+ } = {}) {
39
+ const options = {
40
+ channel,
41
+ executable: executable ? path.resolve(executable) : null,
42
+ import: importFrom ? parseImport(importFrom) : null,
43
+ attach: attach ? parseAttach(attach) : null,
44
+ preferences: parsePreferences(preferences),
45
+ restrictions: validateRestrictions(restrictions),
46
+ };
47
+ if (options.attach && options.import) {
48
+ throw new Error(
49
+ "--browser-attach cannot be combined with --browser-import",
50
+ );
51
+ }
52
+ if (options.attach && profileGiven) {
53
+ throw new Error(
54
+ "--browser-attach cannot be combined with --browser-profile",
55
+ );
56
+ }
57
+ if (
58
+ options.attach?.mode === "extension" &&
59
+ (options.executable ||
60
+ options.restrictions.length > 0 ||
61
+ Object.keys(options.preferences).length > 0)
62
+ ) {
63
+ throw new Error(
64
+ "--browser-attach extension cannot be combined with --browser-executable, --browser-pref, or --browser-restriction",
65
+ );
66
+ }
67
+ return options;
68
+ }
69
+
70
+ /** Parses `<chrome|edge|brave|firefox>[:profile]`. */
71
+ export function parseImport(spec) {
72
+ const separator = spec.indexOf(":");
73
+ const browser = separator === -1 ? spec : spec.slice(0, separator);
74
+ const profile = separator === -1 ? null : spec.slice(separator + 1);
75
+ if (!IMPORT_BROWSERS.includes(browser) || profile === "") {
76
+ throw new Error(
77
+ "--browser-import must be <chrome|edge|brave|firefox>[:profile]",
78
+ );
79
+ }
80
+ return { browser, profile };
81
+ }
82
+
83
+ /** Parses `snapshot`, `snapshot:<profile>`, or `extension`. */
84
+ export function parseAttach(spec) {
85
+ if (spec === "extension") {
86
+ return { mode: "extension" };
87
+ }
88
+ const match = /^snapshot(?::(.+))?$/.exec(spec);
89
+ if (!match) {
90
+ throw new Error(
91
+ "--browser-attach must be 'snapshot', 'snapshot:<profile>', or 'extension'",
92
+ );
93
+ }
94
+ return { mode: "snapshot", profile: match[1] ?? null };
95
+ }
96
+
97
+ /**
98
+ * Builds the preferences object from `key=value` pairs. Dotted keys nest, and
99
+ * a value is JSON when it parses as JSON, otherwise a string.
100
+ */
101
+ export function parsePreferences(pairs) {
102
+ const preferences = {};
103
+ for (const pair of pairs) {
104
+ const separator = pair.indexOf("=");
105
+ const key = separator === -1 ? "" : pair.slice(0, separator);
106
+ if (!/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(key)) {
107
+ throw new Error(`--browser-pref must be key=value, got '${pair}'`);
108
+ }
109
+ const parts = key.split(".");
110
+ const last = parts.pop();
111
+ let target = preferences;
112
+ for (const part of parts) {
113
+ if (!isObject(target[part])) {
114
+ target[part] = {};
115
+ }
116
+ target = target[part];
117
+ }
118
+ target[last] = preferenceValue(pair.slice(separator + 1));
119
+ }
120
+ return preferences;
121
+ }
122
+
123
+ function preferenceValue(text) {
124
+ try {
125
+ const value = JSON.parse(text);
126
+ return Number.isFinite(value) || typeof value !== "number" ? value : text;
127
+ } catch {
128
+ return text;
129
+ }
130
+ }
131
+
132
+ function isObject(value) {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ /** Rejects a restriction or preset that is not in the shared catalogue. */
137
+ export function validateRestrictions(names) {
138
+ const known = restrictionNames();
139
+ for (const name of names) {
140
+ if (!known.includes(name)) {
141
+ throw new Error(
142
+ `unknown --browser-restriction '${name}'; choose from ${known.join(", ")}`,
143
+ );
144
+ }
145
+ }
146
+ return [...new Set(names)];
147
+ }
148
+
149
+ /** The installed browser a snapshot copies for a launch channel. */
150
+ export function snapshotBrowser(channel) {
151
+ if (channel.startsWith("msedge")) {
152
+ return "edge";
153
+ }
154
+ return ["brave", "chromium"].includes(channel) ? channel : "chrome";
155
+ }
156
+
157
+ /** Describes where forms are filled, for the browser prerequisite. */
158
+ export function automatedDescription({
159
+ channel = "chrome",
160
+ profile,
161
+ attach,
162
+ import: source,
163
+ } = {}) {
164
+ if (attach?.mode === "extension") {
165
+ return "your own browser through the Browser Commander extension";
166
+ }
167
+ if (attach?.mode === "snapshot") {
168
+ return `a temporary snapshot of your ${snapshotBrowser(channel)} profile ${attach.profile ?? "Default"}`;
169
+ }
170
+ const location = profile ? ` at ${profile}` : "";
171
+ const imported = source
172
+ ? ` with data imported from ${source.browser}${source.profile ? `:${source.profile}` : ""}`
173
+ : "";
174
+ return `the automated ${channel} profile${location}${imported}`;
175
+ }