package-registry-manager 0.22.2 → 0.22.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "exports": "./src/index.mjs",
11
11
  "engines": {
12
- "node": ">=22"
12
+ "node": ">=22.12.0"
13
13
  },
14
14
  "repository": {
15
15
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "provenance": true
29
29
  },
30
30
  "scripts": {
31
- "syntax": "node --check src/cli.mjs && node --check src/direct-execution.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 src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
31
+ "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-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 src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
32
32
  "lint": "eslint src test scripts --max-warnings 0",
33
33
  "lint:fix": "eslint src test scripts --fix --max-warnings 0",
34
34
  "format": "prettier --write .",
@@ -36,16 +36,18 @@
36
36
  "check:duplication": "jscpd src",
37
37
  "check": "npm run syntax && npm run lint && npm run format:check && npm run check:duplication",
38
38
  "test": "node --test --test-timeout=30000",
39
- "pack:check": "npm pack --dry-run"
39
+ "pack:check": "npm pack --dry-run",
40
+ "audit": "node scripts/npm-audit.mjs"
40
41
  },
41
42
  "dependencies": {
42
- "browser-commander": "^0.22.0",
43
- "command-stream": "^1.3.0",
44
- "playwright": "^1.63.0"
43
+ "browser-commander": "^0.25.0",
44
+ "command-stream": "^1.6.2",
45
+ "playwright": "^1.63.0",
46
+ "selenium-webdriver": "^4.50.0"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@eslint/js": "^10.0.1",
48
- "eslint": "^10.11.0",
50
+ "eslint": "^10.12.0",
49
51
  "eslint-config-prettier": "^10.1.8",
50
52
  "jscpd": "^5.4.0",
51
53
  "prettier": "^3.9.9"
@@ -5,8 +5,11 @@ import {
5
5
  attachViaExtension,
6
6
  EXTENSION_DIRECTORY,
7
7
  launchRealBrowser,
8
+ findBrowserSource,
8
9
  } from "browser-commander";
9
10
 
11
+ import { connectWebDriverAutomation } from "./webdriver-automation.mjs";
12
+
10
13
  import { snapshotBrowser } from "./browser-options.mjs";
11
14
  import { defaultBrowserProfile, ensureProfileIgnored } from "./profile.mjs";
12
15
  import { IMPORT_CHOICES, migrationFor } from "./sign-in-import.mjs";
@@ -28,7 +31,10 @@ export function relayExtensionDirectory() {
28
31
  */
29
32
  export function launchOptions(browser, profile, verbose = false, domains = []) {
30
33
  const options = {
31
- engine: "playwright",
34
+ engine:
35
+ findBrowserSource(browser.channel)?.controlProtocol === "cdp"
36
+ ? "playwright"
37
+ : "selenium",
32
38
  channel: browser.channel,
33
39
  headless: false,
34
40
  verbose,
@@ -75,25 +81,29 @@ export function extensionInstructions(directory) {
75
81
  * Connects the browser that fills forms and returns a page with `goto`,
76
82
  * `evaluate`, `close`, and, for a launched profile, `clearCookies(domains)`.
77
83
  */
78
- export async function connectAutomation({
79
- browser,
80
- profile,
81
- verbose = false,
82
- domains = [],
83
- }) {
84
+ export async function connectAutomation(
85
+ { browser, profile, verbose = false, domains = [] },
86
+ dependencies = {},
87
+ ) {
84
88
  if (browser.attach?.mode === "extension") {
85
89
  return connectExtension();
86
90
  }
91
+ if (findBrowserSource(browser.channel)?.controlProtocol === "bidi") {
92
+ return connectWebDriverAutomation(
93
+ { browser, profile, verbose, domains },
94
+ dependencies,
95
+ );
96
+ }
87
97
  if (!browser.attach) {
88
98
  await ensureProfileIgnored(profile, { verbose });
89
99
  }
90
- const connection = await launchRealBrowser(
91
- launchOptions(browser, profile, verbose, domains),
92
- );
100
+ const connection = await (
101
+ dependencies.launchRealBrowser ?? launchRealBrowser
102
+ )(launchOptions(browser, profile, verbose, domains));
93
103
  return {
94
104
  goto: (url) => connection.page.goto(url),
95
105
  evaluate: (script) => connection.page.evaluate(script),
96
- close: () => connection.browser.close(),
106
+ close: () => connection.close(),
97
107
  async clearCookies(names) {
98
108
  for (const name of names) {
99
109
  const escaped = name.replaceAll(".", "\\.");
@@ -0,0 +1,209 @@
1
+ import { constants, existsSync, realpathSync } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { BROWSER_IDS, findBrowserSource } from "browser-commander";
7
+
8
+ /** Catalogue entries reached through Browser Commander's public exports. */
9
+ export const browserSources = () => BROWSER_IDS.map(findBrowserSource);
10
+
11
+ /** Catalogue channels with a launch control protocol. */
12
+ export function launchChannels() {
13
+ return browserSources()
14
+ .filter((source) => source.controlProtocol)
15
+ .flatMap((source) => [source.id, ...(source.aliases ?? [])]);
16
+ }
17
+
18
+ /** A readable name from the catalogue's application path or canonical alias. */
19
+ export function catalogueName(id) {
20
+ const source = findBrowserSource(id);
21
+ if (!source) {
22
+ return id;
23
+ }
24
+ const app = source.executables?.darwin?.[0]?.match(/\/([^/]+)\.app\//)?.[1];
25
+ // Preserve the established short name of Brave.
26
+ if (app) {
27
+ return app === "Brave Browser" ? "Brave" : app;
28
+ }
29
+ const label = [source.id, ...(source.aliases ?? [])]
30
+ .map((name) => name.replace(/^apple-/, ""))
31
+ .reduce((longest, name) => (name.length > longest.length ? name : longest));
32
+ return label
33
+ .replace(/^apple-/, "")
34
+ .split("-")
35
+ .map((word) =>
36
+ word.length <= 2
37
+ ? word.toUpperCase()
38
+ : word[0].toUpperCase() + word.slice(1),
39
+ )
40
+ .join(" ")
41
+ .replace("Duckduckgo", "DuckDuckGo");
42
+ }
43
+
44
+ /** Installed executables or existing profile roots; never reads cookie data. */
45
+ export function installedBrowsers(options = {}) {
46
+ const exists = options.exists ?? existsSync;
47
+ const platform = options.platform ?? process.platform;
48
+ const environment = options.environment ?? process.env;
49
+ const discovery = {
50
+ ...options,
51
+ platform,
52
+ environment:
53
+ platform === "win32"
54
+ ? Object.fromEntries(
55
+ Object.entries(environment).map(([key, value]) => [
56
+ key.toUpperCase(),
57
+ value,
58
+ ]),
59
+ )
60
+ : environment,
61
+ };
62
+ return browserSources()
63
+ .filter((source) =>
64
+ installedCandidates(source, discovery).some((candidate) =>
65
+ exists(candidate),
66
+ ),
67
+ )
68
+ .map((source) => source.id);
69
+ }
70
+
71
+ function installedCandidates(
72
+ source,
73
+ {
74
+ platform = process.platform,
75
+ homeDir = os.homedir(),
76
+ environment = process.env,
77
+ },
78
+ ) {
79
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
80
+ const variables = {
81
+ home: homeDir,
82
+ config: environment.XDG_CONFIG_HOME ?? pathApi.join(homeDir, ".config"),
83
+ appSupport: pathApi.join(homeDir, "Library", "Application Support"),
84
+ appData: environment.APPDATA ?? pathApi.join(homeDir, "AppData", "Roaming"),
85
+ localAppData:
86
+ environment.LOCALAPPDATA ?? pathApi.join(homeDir, "AppData", "Local"),
87
+ programFiles: environment.PROGRAMFILES,
88
+ programFilesX86: environment["PROGRAMFILES(X86)"],
89
+ };
90
+ const templates = [
91
+ ...(source.executables?.[platform] ?? []),
92
+ ...(source.roots?.[platform] ?? []),
93
+ ];
94
+ const candidates = templates.flatMap((template) => {
95
+ const match = /^\{(\w+)\}\/?(.*)$/.exec(template);
96
+ if (!match) {
97
+ return [template];
98
+ }
99
+ const base = variables[match[1]];
100
+ return base === undefined
101
+ ? []
102
+ : [pathApi.join(base, ...match[2].split("/"))];
103
+ });
104
+ for (const directory of (environment.PATH ?? "")
105
+ .split(platform === "win32" ? ";" : ":")
106
+ .filter(Boolean)) {
107
+ for (const name of source.executableNames ?? []) {
108
+ candidates.push(
109
+ pathApi.join(directory, platform === "win32" ? `${name}.exe` : name),
110
+ );
111
+ }
112
+ }
113
+ return candidates;
114
+ }
115
+
116
+ /** Discovery information included when an import or channel is invalid. */
117
+ export function installedDescription(options = {}) {
118
+ const found = installedBrowsers(options);
119
+ return `Installed browsers found: ${found.join(", ") || "none"}.`;
120
+ }
121
+
122
+ /** Reject unavailable engines before creating an automated profile. */
123
+ export function validateChannel(channel) {
124
+ const known = findBrowserSource(channel);
125
+ if (known?.controlProtocol) {
126
+ return channel;
127
+ }
128
+ const reason = known
129
+ ? `${channel} has no launch control protocol in browser-commander's catalogue${known.family === "safari" ? " (Safari setup: https://github.com/link-foundation/browser-commander/issues/126)" : ""}`
130
+ : `unknown --browser-channel '${channel}'`;
131
+ throw new Error(
132
+ `${reason}; choose from ${launchChannels().join(", ")}. ${installedDescription()}`,
133
+ );
134
+ }
135
+
136
+ /** Resolves the selected installed binary using catalogue paths and PATH. */
137
+ export async function resolveExecutable(
138
+ { channel, executablePath },
139
+ discovery = {},
140
+ ) {
141
+ const platform = discovery.platform ?? process.platform;
142
+ const environment = discovery.environment ?? process.env;
143
+ const options = {
144
+ ...discovery,
145
+ platform,
146
+ environment:
147
+ platform === "win32"
148
+ ? Object.fromEntries(
149
+ Object.entries(environment).map(([key, value]) => [
150
+ key.toUpperCase(),
151
+ value,
152
+ ]),
153
+ )
154
+ : environment,
155
+ };
156
+ const source = findBrowserSource(channel);
157
+ const candidates = executablePath
158
+ ? [executablePath]
159
+ : installedCandidates({ ...source, roots: {} }, options);
160
+ for (const candidate of candidates) {
161
+ try {
162
+ await (discovery.access ?? access)(candidate, constants.X_OK);
163
+ return candidate;
164
+ } catch {
165
+ // Continue through the catalogue's executable paths and PATH names.
166
+ }
167
+ }
168
+ throw new Error(
169
+ `could not find an installed ${channel} browser; use --browser-executable`,
170
+ );
171
+ }
172
+
173
+ /** Refuses a source profile even when reached through a symlink. */
174
+ export function assertDedicatedProfile(profile, discovery = {}) {
175
+ const physical = (file) => {
176
+ try {
177
+ return realpathSync(file);
178
+ } catch (error) {
179
+ if (error.code !== "ENOENT") {
180
+ throw error;
181
+ }
182
+ return path.join(physical(path.dirname(file)), path.basename(file));
183
+ }
184
+ };
185
+ const requested = physical(path.resolve(profile));
186
+ const roots = browserSources().flatMap((source) =>
187
+ installedCandidates(
188
+ {
189
+ ...source,
190
+ executables: {},
191
+ executableNames: [],
192
+ },
193
+ discovery,
194
+ ),
195
+ );
196
+ for (const root of roots) {
197
+ const relative = path.relative(physical(path.resolve(root)), requested);
198
+ if (
199
+ relative === "" ||
200
+ (!relative.startsWith(`..${path.sep}`) &&
201
+ relative !== ".." &&
202
+ !path.isAbsolute(relative))
203
+ ) {
204
+ throw new Error(
205
+ "automation requires a dedicated profile, not a browser default profile; use --browser-import to reuse sign-in",
206
+ );
207
+ }
208
+ }
209
+ }
@@ -1,10 +1,12 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import {
4
+ findBrowserSource,
4
5
  LAUNCH_RESTRICTION_PRESETS,
5
6
  LAUNCH_RESTRICTIONS,
6
7
  } from "browser-commander";
7
8
 
9
+ import { installedDescription, validateChannel } from "./browser-catalogue.mjs";
8
10
  import {
9
11
  IMPORT_CHOICES,
10
12
  IMPORT_SCOPES,
@@ -50,10 +52,14 @@ export function parseBrowserOptions({
50
52
  profileGiven = false,
51
53
  } = {}) {
52
54
  const options = {
53
- channel,
55
+ channel: attach === "extension" ? channel : validateChannel(channel),
54
56
  executable: executable ? path.resolve(executable) : null,
55
57
  import: importFrom ? parseImport(importFrom) : null,
56
- importScope: parseImportScope(importScope, importFrom),
58
+ importScope:
59
+ (importScope === undefined || importScope === null) &&
60
+ findBrowserSource(channel)?.controlProtocol === "bidi"
61
+ ? "domains"
62
+ : parseImportScope(importScope, importFrom),
57
63
  attach: attach ? parseAttach(attach) : null,
58
64
  preferences: parsePreferences(preferences),
59
65
  restrictions: validateRestrictions(restrictions),
@@ -78,25 +84,47 @@ export function parseBrowserOptions({
78
84
  "--browser-attach extension cannot be combined with --browser-executable, --browser-pref, or --browser-restriction",
79
85
  );
80
86
  }
87
+ validateEngineOptions(options);
81
88
  return options;
82
89
  }
83
90
 
91
+ /** Rejects capabilities the installed upstream non-CDP launchers lack. */
92
+ function validateEngineOptions(options) {
93
+ if (
94
+ options.attach?.mode === "extension" ||
95
+ findBrowserSource(options.channel)?.controlProtocol === "cdp"
96
+ ) {
97
+ return;
98
+ }
99
+ const unsupported = options.attach
100
+ ? "--browser-attach snapshot"
101
+ : options.restrictions.length
102
+ ? "--browser-restriction"
103
+ : Object.keys(options.preferences).length
104
+ ? "--browser-pref"
105
+ : options.import && options.importScope === "full"
106
+ ? "--browser-import-scope full"
107
+ : null;
108
+ if (unsupported) {
109
+ throw new Error(
110
+ `${unsupported} is unavailable for ${options.channel} in the installed browser-commander; use --browser-import-scope domains for sign-in cookies`,
111
+ );
112
+ }
113
+ }
114
+
84
115
  /** Parses `<browser>[:profile]`, `default`, or `auto`. */
85
- export function parseImport(spec) {
116
+ export function parseImport(spec, discovery = {}) {
86
117
  const separator = spec.indexOf(":");
87
118
  const browser = separator === -1 ? spec : spec.slice(0, separator);
88
119
  const profile = separator === -1 ? null : spec.slice(separator + 1);
89
120
  const choice = IMPORT_CHOICES.includes(browser);
90
- if (
91
- !importBrowsers().includes(browser) ||
92
- profile === "" ||
93
- (choice && profile !== null)
94
- ) {
121
+ const source = findBrowserSource(browser);
122
+ if ((!source && !choice) || profile === "" || (choice && profile !== null)) {
95
123
  throw new Error(
96
- `--browser-import must be <${importSources().join("|")}>[:profile], default, or auto`,
124
+ `--browser-import must be <${importSources().join("|")}>[:profile], default, or auto. ${installedDescription(discovery)}`,
97
125
  );
98
126
  }
99
- return { browser, profile };
127
+ return { browser: source?.id ?? browser, profile };
100
128
  }
101
129
 
102
130
  /**
@@ -184,10 +212,7 @@ export function validateRestrictions(names) {
184
212
 
185
213
  /** The installed browser a snapshot copies for a launch channel. */
186
214
  export function snapshotBrowser(channel) {
187
- if (channel.startsWith("msedge")) {
188
- return "edge";
189
- }
190
- return ["brave", "chromium"].includes(channel) ? channel : "chrome";
215
+ return findBrowserSource(channel)?.id ?? channel;
191
216
  }
192
217
 
193
218
  /** Describes where forms are filled, for the browser prerequisite. */
package/src/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { parseArgs } from "node:util";
8
8
 
9
+ import { launchChannels } from "./browser-catalogue.mjs";
9
10
  import { parseBrowserOptions } from "./browser-options.mjs";
10
11
  import { isDirectExecution } from "./direct-execution.mjs";
11
12
  import { inspectRepository } from "./discovery.mjs";
@@ -14,6 +15,7 @@ import { buildPlans } from "./plan.mjs";
14
15
  import { probeEnvironment, renderPrerequisites } from "./prerequisites.mjs";
15
16
  import { probeRegistryState } from "./registry-state.mjs";
16
17
  import { BROWSER_MODES, defaultBrowserProfile, executePlan } from "./setup.mjs";
18
+ import { importSources } from "./sign-in-import.mjs";
17
19
 
18
20
  export { isDirectExecution };
19
21
 
@@ -62,17 +64,16 @@ Setup options:
62
64
  --browser <default|automated> Open sign-in and approval pages in your
63
65
  default browser, or in the automated
64
66
  profile too (default: default)
65
- --browser-channel <channel> Installed browser channel: chrome, chromium,
66
- brave, msedge, msedge-beta, msedge-dev, or
67
- msedge-canary (default: chrome)
67
+ --browser-channel <channel> Installed browser channel (default: chrome):
68
+ ${launchChannels().join(", ")}
68
69
  --browser-executable <path> Installed browser executable to launch
69
70
  instead of the channel's
70
71
  --browser-profile <path> Dedicated automation profile, used to fill
71
72
  forms (default: per-user state directory)
72
73
  --browser-import <browser>[:<profile>]|default|auto
73
- Copy data from your chrome, edge, brave,
74
- chromium, or firefox profile into the
75
- automated profile first; default takes the
74
+ Copy data from your installed browser profile:
75
+ ${importSources().join(", ")}
76
+ into the automated profile; default takes the
76
77
  system default browser, auto the first
77
78
  browser signed in to the registry
78
79
  --browser-import-scope <full|domains>
@@ -226,7 +227,8 @@ export async function main(args = process.argv.slice(2)) {
226
227
  browser: values.browser,
227
228
  browserOptions,
228
229
  browserProfile: path.resolve(
229
- values["browser-profile"] ?? defaultBrowserProfile(),
230
+ values["browser-profile"] ??
231
+ defaultBrowserProfile({ channel: browserOptions.channel }),
230
232
  ),
231
233
  verbose: values.verbose,
232
234
  verifyRelease: values["verify-release"],
@@ -272,7 +274,10 @@ function browserSummary(command, values, browserOptions) {
272
274
  channel: browserOptions.channel,
273
275
  profile:
274
276
  values.browser === "automated" && !browserOptions.attach
275
- ? path.resolve(values["browser-profile"] ?? defaultBrowserProfile())
277
+ ? path.resolve(
278
+ values["browser-profile"] ??
279
+ defaultBrowserProfile({ channel: browserOptions.channel }),
280
+ )
276
281
  : undefined,
277
282
  import: browserOptions.import ?? undefined,
278
283
  attach: browserOptions.attach ?? undefined,
@@ -1,22 +1,6 @@
1
1
  import { exec } from "command-stream";
2
2
 
3
- /**
4
- * Names of common browsers by macOS bundle id, Linux desktop entry, and
5
- * Windows URL ProgId, so the tool can say which browser it opened.
6
- */
7
- const KNOWN_BROWSERS = [
8
- [/^com\.apple\.safari$|^SafariURL/i, "Safari"],
9
- [/^com\.google\.chrome\.canary$/i, "Google Chrome Canary"],
10
- [/^com\.google\.chrome$|google-chrome|^ChromeHTML$/i, "Google Chrome"],
11
- [/^org\.chromium\.chromium$|chromium|^ChromiumHTM/i, "Chromium"],
12
- [/^org\.mozilla\.firefox$|firefox|^FirefoxURL/i, "Firefox"],
13
- [/^com\.microsoft\.edgemac$|microsoft-edge|^MSEdgeHTM$/i, "Microsoft Edge"],
14
- [/^com\.brave\.browser$|brave-browser|^BraveHTML$/i, "Brave"],
15
- [/^company\.thebrowser\.browser$/i, "Arc"],
16
- [/^com\.operasoftware\.opera$|opera|^OperaStable$/i, "Opera"],
17
- [/^com\.vivaldi\.vivaldi$|vivaldi|^VivaldiHTM/i, "Vivaldi"],
18
- [/^IE\.HTTP/i, "Internet Explorer"],
19
- ];
3
+ import { browserSources, catalogueName } from "./browser-catalogue.mjs";
20
4
 
21
5
  /** The command that reports the default browser, as an exact argument vector. */
22
6
  export function defaultBrowserQuery(platform = process.platform) {
@@ -100,8 +84,40 @@ export function browserName(id) {
100
84
  if (!id) {
101
85
  return undefined;
102
86
  }
103
- const known = KNOWN_BROWSERS.find(([pattern]) => pattern.test(id));
104
- return known?.[1];
87
+ const needle = id.trim().toLowerCase();
88
+ const sources = browserSources();
89
+ const known =
90
+ sources.find((source) =>
91
+ Object.values(source.default ?? {})
92
+ .flat()
93
+ .some((candidate) => needle === candidate.toLowerCase()),
94
+ ) ??
95
+ sources.find(
96
+ (source) =>
97
+ (source.default?.win32 ?? []).some((candidate) =>
98
+ needle.startsWith(candidate.toLowerCase()),
99
+ ) ||
100
+ (needle.endsWith(".desktop") &&
101
+ (source.executableNames ?? []).some((name) =>
102
+ ["-", "_", "."].some((separator) =>
103
+ needle.startsWith(`${name.toLowerCase()}${separator}`),
104
+ ),
105
+ )),
106
+ );
107
+ if (known) {
108
+ return catalogueName(known.id);
109
+ }
110
+ // Legacy ProgIds absent from the upstream catalogue remain recognizable.
111
+ if (/^safariurl/i.test(id)) {
112
+ return catalogueName("safari");
113
+ }
114
+ if (/^chromiumhtm/i.test(id)) {
115
+ return catalogueName("chromium");
116
+ }
117
+ if (/^ie\.http/i.test(id)) {
118
+ return "Internet Explorer";
119
+ }
120
+ return undefined;
105
121
  }
106
122
 
107
123
  /**
package/src/profile.mjs CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  import { exec } from "command-stream";
6
+ import { findBrowserSource } from "browser-commander";
6
7
 
7
8
  const APPLICATION = "package-registry-manager";
8
9
  const PROFILE = "browser-profile";
@@ -17,20 +18,26 @@ export function defaultBrowserProfile({
17
18
  platform = process.platform,
18
19
  env = process.env,
19
20
  home = os.homedir(),
21
+ channel = "chrome",
20
22
  } = {}) {
23
+ const source = findBrowserSource(channel);
24
+ const profile =
25
+ source?.controlProtocol && source.controlProtocol !== "cdp"
26
+ ? `${PROFILE}-${source.id}`
27
+ : PROFILE;
21
28
  const join = platform === "win32" ? path.win32.join : path.posix.join;
22
29
  if (platform === "win32") {
23
30
  const base = env.LOCALAPPDATA || join(home, "AppData", "Local");
24
- return join(base, APPLICATION, PROFILE);
31
+ return join(base, APPLICATION, profile);
25
32
  }
26
33
  if (platform === "darwin") {
27
- return join(home, "Library", "Application Support", APPLICATION, PROFILE);
34
+ return join(home, "Library", "Application Support", APPLICATION, profile);
28
35
  }
29
36
  // The XDG specification ignores relative values.
30
37
  const state = path.posix.isAbsolute(env.XDG_STATE_HOME ?? "")
31
38
  ? env.XDG_STATE_HOME
32
39
  : join(home, ".local", "state");
33
- return join(state, APPLICATION, PROFILE);
40
+ return join(state, APPLICATION, profile);
34
41
  }
35
42
 
36
43
  /** The profile location used before it moved out of the repository. */
@@ -5,10 +5,9 @@
5
5
  // printed, logged, or cached: they are only counted.
6
6
  import * as commander from "browser-commander";
7
7
 
8
+ import { catalogueName } from "./browser-catalogue.mjs";
8
9
  import { detectDefaultBrowser } from "./default-browser.mjs";
9
10
 
10
- /** Browser Commander's cookie sources, should a release stop exporting them. */
11
- const FALLBACK_SOURCES = ["chrome", "edge", "brave", "chromium", "firefox"];
12
11
  /** `--browser-import` values that pick the source browser for the user. */
13
12
  export const IMPORT_CHOICES = ["default", "auto"];
14
13
  /** `--browser-import-scope` values. */
@@ -21,22 +20,9 @@ export const SIGN_IN_DOMAINS = {
21
20
  pypi: ["pypi.org", "github.com"],
22
21
  };
23
22
 
24
- /** Display names of the source browsers, as the default-browser query names them. */
25
- const SOURCE_NAMES = {
26
- chrome: "Google Chrome",
27
- edge: "Microsoft Edge",
28
- brave: "Brave",
29
- chromium: "Chromium",
30
- firefox: "Firefox",
31
- };
32
-
33
- /**
34
- * Every browser `--browser-import` can read, from Browser Commander. Safari,
35
- * Opera, Vivaldi, and Arc follow once Browser Commander reads them
36
- * (link-foundation/browser-commander#114).
37
- */
23
+ /** Every import id from Browser Commander's public catalogue. */
38
24
  export function importSources() {
39
- return [...(commander.SUPPORTED_COOKIE_BROWSERS ?? FALLBACK_SOURCES)];
25
+ return [...commander.BROWSER_IDS];
40
26
  }
41
27
 
42
28
  /** The sign-in domains of a registry, or none. */
@@ -46,15 +32,12 @@ export function signInDomains(registry) {
46
32
 
47
33
  /** The display name of a source browser. */
48
34
  export function sourceName(browser) {
49
- return SOURCE_NAMES[browser] ?? browser;
35
+ return catalogueName(browser);
50
36
  }
51
37
 
52
38
  /** The source id of a default-browser display name, when it can be imported. */
53
39
  export function sourceId(displayName) {
54
- const entry = Object.entries(SOURCE_NAMES).find(
55
- ([, name]) => name === displayName,
56
- );
57
- return entry && importSources().includes(entry[0]) ? entry[0] : undefined;
40
+ return importSources().find((id) => sourceName(id) === displayName);
58
41
  }
59
42
 
60
43
  /**
@@ -87,7 +70,12 @@ export async function findSignInSources(domains, options = {}) {
87
70
  );
88
71
  const found = [];
89
72
  for (const browser of browsers) {
90
- const profiles = await list({ browser }).catch(() => []);
73
+ const profiles = await list({ browser }).catch((error) => {
74
+ if (options.verbose) {
75
+ console.error(`could not list ${browser} profiles: ${error.message}`);
76
+ }
77
+ return [];
78
+ });
91
79
  for (const profile of profiles) {
92
80
  const matched = [];
93
81
  for (const domain of domains) {
@@ -171,15 +159,15 @@ export async function chooseSignInSource(sources, domains, ask, auto) {
171
159
  * Resolves `--browser-import default|auto` to an installed source browser:
172
160
  * the system default browser, or for `auto` the first browser with a
173
161
  * sign-in for `domains`. Throws when `default` names a browser Browser
174
- * Commander cannot read yet, such as Safari.
162
+ * Commander cannot identify.
175
163
  */
176
164
  export async function resolveImportChoice(choice, domains, options = {}) {
177
165
  const preferred = await defaultSource(options);
178
166
  if (choice === "default") {
179
167
  if (!preferred) {
180
168
  throw new Error(
181
- "--browser-import default: the default browser cannot be imported yet (Browser Commander reads " +
182
- `${importSources().join(", ")}; see link-foundation/browser-commander#114)`,
169
+ "--browser-import default: the default browser is unknown or cannot be imported (Browser Commander sources: " +
170
+ `${importSources().join(", ")})`,
183
171
  );
184
172
  }
185
173
  return { browser: preferred, profile: null };
@@ -0,0 +1,125 @@
1
+ // Firefox-family browsers use Browser Commander's native WebDriver/BiDi API.
2
+ // Its real/CDP launcher and migration target writers do not support them yet.
3
+ import { launchWebDriver, readBrowserCookies } from "browser-commander";
4
+
5
+ import {
6
+ assertDedicatedProfile,
7
+ resolveExecutable,
8
+ } from "./browser-catalogue.mjs";
9
+ import { ensureProfileIgnored } from "./profile.mjs";
10
+ import { IMPORT_CHOICES } from "./sign-in-import.mjs";
11
+
12
+ /** True only for a requested host or its subdomains, never a substring. */
13
+ export function matchesDomains(host, domains) {
14
+ const normalized = host.replace(/^\./, "").toLowerCase();
15
+ return domains.some(
16
+ (domain) => normalized === domain || normalized.endsWith(`.${domain}`),
17
+ );
18
+ }
19
+
20
+ /** The BiDi cookie shape; session cookies omit expiry. */
21
+ export function bidiCookie(cookie) {
22
+ const result = {
23
+ name: cookie.name,
24
+ value: { type: "string", value: cookie.value },
25
+ domain: cookie.domain,
26
+ path: cookie.path ?? "/",
27
+ httpOnly: cookie.httpOnly ?? false,
28
+ secure: cookie.secure ?? false,
29
+ sameSite: (cookie.sameSite ?? "Lax").toLowerCase(),
30
+ };
31
+ if (cookie.expires > 0) {
32
+ result.expiry = cookie.expires;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ /** Launches the selected installed Firefox variant and exposes the common page API. */
38
+ export async function connectWebDriverAutomation(
39
+ { browser, profile, verbose, domains },
40
+ dependencies = {},
41
+ ) {
42
+ // Validation happens before profile creation, including programmatic callers.
43
+ if (
44
+ browser.attach ||
45
+ browser.restrictions.length ||
46
+ Object.keys(browser.preferences).length ||
47
+ (browser.import && browser.importScope === "full")
48
+ ) {
49
+ throw new Error(
50
+ `unsupported Firefox launch options for ${browser.channel}; use --browser-import-scope domains without snapshot, preferences or restrictions`,
51
+ );
52
+ }
53
+ if (browser.import && domains.length === 0) {
54
+ throw new Error("a domains import needs the registry's sign-in domains");
55
+ }
56
+ const executablePath = await (
57
+ dependencies.resolveExecutable ?? resolveExecutable
58
+ )({
59
+ channel: browser.channel.toLowerCase(),
60
+ executablePath: browser.executable,
61
+ });
62
+ assertDedicatedProfile(profile);
63
+ await ensureProfileIgnored(profile, { verbose });
64
+ const connection = await (dependencies.launchWebDriver ?? launchWebDriver)({
65
+ browser: "firefox",
66
+ executablePath,
67
+ userDataDir: profile,
68
+ headless: false,
69
+ bidi: true,
70
+ verbose,
71
+ });
72
+ try {
73
+ await connection.page.bidiCommand("storage.getCookies", {});
74
+ if (browser.import && !IMPORT_CHOICES.includes(browser.import.browser)) {
75
+ const read = dependencies.readCookies ?? readBrowserCookies;
76
+ const seen = new Set();
77
+ for (const domain of domains) {
78
+ const cookies = await read({
79
+ browser: browser.import.browser,
80
+ profile: browser.import.profile ?? undefined,
81
+ domainFilter: domain,
82
+ cache: false,
83
+ });
84
+ for (const cookie of cookies) {
85
+ const key = JSON.stringify([cookie.domain, cookie.path, cookie.name]);
86
+ if (!matchesDomains(cookie.domain, domains) || seen.has(key)) {
87
+ continue;
88
+ }
89
+ seen.add(key);
90
+ await connection.page.bidiCommand("storage.setCookie", {
91
+ cookie: bidiCookie(cookie),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ } catch (error) {
97
+ await connection.close().catch(() => {});
98
+ throw new Error(
99
+ `could not prepare ${browser.channel} sign-in cookies (WebDriver BiDi storage is required)`,
100
+ { cause: error },
101
+ );
102
+ }
103
+ return {
104
+ goto: (url) => connection.page.goto(url),
105
+ evaluate: (script) => connection.page.evaluate(script),
106
+ close: () => connection.close(),
107
+ async clearCookies(names) {
108
+ const { cookies } = await connection.page.bidiCommand(
109
+ "storage.getCookies",
110
+ {},
111
+ );
112
+ for (const cookie of cookies) {
113
+ if (matchesDomains(cookie.domain, names)) {
114
+ await connection.page.bidiCommand("storage.deleteCookies", {
115
+ filter: {
116
+ name: cookie.name,
117
+ domain: cookie.domain,
118
+ path: cookie.path,
119
+ },
120
+ });
121
+ }
122
+ }
123
+ },
124
+ };
125
+ }