browser-cookie-bridge 1.1.0 → 1.3.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/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "browser-cookie-bridge",
3
- "version": "1.1.0",
4
- "description": "Private local cookie and session transfer for macOS",
3
+ "version": "1.3.0",
4
+ "description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "browser-cookie-bridge": "./bin/brave-codex-cookie-sync.js"
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test test/*.test.js",
11
- "check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
11
+ "check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/browserless.js && node --check src/browserless-preflight.js && node --check src/browserless-runner.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
12
12
  "release:check": "node scripts/check-release-version.js",
13
13
  "build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open",
14
14
  "build:dmg": "node scripts/build-dmg.js",
@@ -16,7 +16,7 @@
16
16
  "web:deploy": "wrangler deploy --config web/wrangler.jsonc"
17
17
  },
18
18
  "engines": {
19
- "node": ">=22.5"
19
+ "node": ">=24"
20
20
  },
21
21
  "license": "MIT",
22
22
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "macos",
39
39
  "chromium",
40
40
  "brave",
41
+ "browserless",
41
42
  "codex"
42
43
  ],
43
44
  "funding": "https://ko-fi.com/apoorvdarshan",
@@ -50,6 +51,10 @@
50
51
  "macos-app/Resources",
51
52
  "macos-app/Sources",
52
53
  "README.md",
53
- "LICENSE"
54
- ]
54
+ "LICENSE",
55
+ "THIRD_PARTY_NOTICES.md"
56
+ ],
57
+ "dependencies": {
58
+ "@browserless.io/cli": "0.3.0"
59
+ }
55
60
  }
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES = 2 * 1024 * 1024;
5
+
6
+ const MEBIBYTE = 1024 * 1024;
7
+ const GIBIBYTE = 1024 * MEBIBYTE;
8
+
9
+ export function inspectBrowserlessProfile({ profilePath } = {}) {
10
+ if (!profilePath || !fs.existsSync(profilePath)) {
11
+ throw new Error("The local browser profile could not be inspected.");
12
+ }
13
+
14
+ const profileBytes = directorySize(profilePath);
15
+ const indexedDBBytes = directorySize(path.join(profilePath, "IndexedDB"));
16
+ const localStorageBytes = directorySize(path.join(profilePath, "Local Storage"));
17
+ const freeBytes = availableBytes(profilePath);
18
+ const severity = sizeSeverity(indexedDBBytes);
19
+ const temporarySpaceWarning = Number.isFinite(freeBytes) && freeBytes < profileBytes + 256 * MEBIBYTE;
20
+
21
+ return {
22
+ profilePath,
23
+ profileBytes,
24
+ indexedDBBytes,
25
+ localStorageBytes,
26
+ freeBytes,
27
+ severity,
28
+ temporarySpaceWarning,
29
+ serverArtifactCapBytes: BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES,
30
+ summary: profileSummary({
31
+ profileBytes,
32
+ indexedDBBytes,
33
+ localStorageBytes,
34
+ freeBytes,
35
+ severity,
36
+ temporarySpaceWarning,
37
+ }),
38
+ };
39
+ }
40
+
41
+ export function sizeSeverity(indexedDBBytes) {
42
+ if (indexedDBBytes >= GIBIBYTE) return "extreme";
43
+ if (indexedDBBytes >= 500 * MEBIBYTE) return "high";
44
+ if (indexedDBBytes >= 100 * MEBIBYTE) return "elevated";
45
+ return "normal";
46
+ }
47
+
48
+ export function formatBytes(bytes) {
49
+ if (!Number.isFinite(bytes) || bytes < 0) return "unknown";
50
+ if (bytes < 1024) return `${bytes} B`;
51
+ const units = ["KB", "MB", "GB", "TB"];
52
+ let value = bytes;
53
+ let unit = -1;
54
+ do {
55
+ value /= 1024;
56
+ unit += 1;
57
+ } while (value >= 1024 && unit < units.length - 1);
58
+ const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
59
+ return `${value.toFixed(digits)} ${units[unit]}`;
60
+ }
61
+
62
+ function directorySize(root) {
63
+ try {
64
+ if (fs.lstatSync(root).isSymbolicLink()) return 0;
65
+ } catch {
66
+ return 0;
67
+ }
68
+ let total = 0;
69
+ const pending = [root];
70
+ while (pending.length > 0) {
71
+ const current = pending.pop();
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(current, { withFileTypes: true });
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const entry of entries) {
79
+ const candidate = path.join(current, entry.name);
80
+ if (entry.isSymbolicLink()) continue;
81
+ if (entry.isDirectory()) {
82
+ pending.push(candidate);
83
+ continue;
84
+ }
85
+ if (!entry.isFile()) continue;
86
+ try {
87
+ total += fs.statSync(candidate).size;
88
+ } catch {}
89
+ }
90
+ }
91
+ return total;
92
+ }
93
+
94
+ function availableBytes(candidate) {
95
+ try {
96
+ const statistics = fs.statfsSync(candidate);
97
+ return Number(statistics.bavail) * Number(statistics.bsize);
98
+ } catch {
99
+ return Number.NaN;
100
+ }
101
+ }
102
+
103
+ function profileSummary({ profileBytes, indexedDBBytes, localStorageBytes, freeBytes, severity, temporarySpaceWarning }) {
104
+ const parts = [
105
+ `${formatBytes(profileBytes)} profile`,
106
+ `${formatBytes(indexedDBBytes)} IndexedDB`,
107
+ `${formatBytes(localStorageBytes)} local storage`,
108
+ ];
109
+ if (Number.isFinite(freeBytes)) parts.push(`${formatBytes(freeBytes)} free`);
110
+ if (temporarySpaceWarning) parts.push("low temporary disk space");
111
+ else if (severity !== "normal") parts.push(`${severity} IndexedDB load`);
112
+ return parts.join(" · ");
113
+ }
@@ -0,0 +1,13 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ const [, , cliPath, ...argumentsWithoutToken] = process.argv;
4
+ const token = process.env.BROWSERLESS_TOKEN?.trim();
5
+ if (!cliPath) throw new Error("Browserless CLI path is missing.");
6
+ if (!token) throw new Error("Browserless API token is missing.");
7
+
8
+ // Read the Keychain-supplied token once, then remove it before the official CLI
9
+ // launches a temporary browser. Mutating process.argv does not alter the OS
10
+ // command line that started this process, so the token is not exposed by `ps`.
11
+ delete process.env.BROWSERLESS_TOKEN;
12
+ process.argv = [process.execPath, cliPath, ...argumentsWithoutToken, "--token", token];
13
+ await import(pathToFileURL(cliPath).href);
@@ -0,0 +1,281 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { projectRoot } from "./paths.js";
6
+
7
+ const SUPPORTED_SOURCES = new Set(["brave", "chrome", "edge", "arc", "vivaldi", "opera"]);
8
+ const REGIONS = new Set(["sfo", "lon", "ams"]);
9
+
10
+ export async function uploadBrowserlessProfile({
11
+ browser,
12
+ localProfile,
13
+ profileName,
14
+ region = "sfo",
15
+ onlyDomains = [],
16
+ token = process.env.BROWSERLESS_TOKEN,
17
+ root = projectRoot(),
18
+ runner = runBrowserlessCLI,
19
+ signal,
20
+ timeoutMs = 15 * 60 * 1000,
21
+ onProgress = () => {},
22
+ } = {}) {
23
+ if (!SUPPORTED_SOURCES.has(browser)) {
24
+ throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
25
+ }
26
+ if (!localProfile) throw new Error("The local browser profile could not be determined.");
27
+ if (!profileName?.trim()) throw new Error("Choose a Browserless cloud profile name.");
28
+ if (!REGIONS.has(region)) throw new Error("Browserless region must be sfo, lon, or ams.");
29
+ if (!token?.trim()) throw new Error("Connect Browserless first. The API token is missing from macOS Keychain.");
30
+
31
+ const cliPath = path.join(root, "node_modules", "@browserless.io", "cli", "build", "cli.js");
32
+ const runnerPath = path.join(root, "src", "browserless-runner.js");
33
+ const environment = {
34
+ ...process.env,
35
+ BROWSERLESS_TOKEN: token.trim(),
36
+ BROWSERLESS_ACCEPT_TERMS: "1",
37
+ BROWSERLESS_TELEMETRY_DISABLED: "1",
38
+ BROWSERLESS_DISABLE_KEYCHAIN: "1",
39
+ DO_NOT_TRACK: "1",
40
+ };
41
+ const client = ["--region", region, "--json"];
42
+ onProgress({ phase: "validating", fraction: 0.10, detail: "Checking the destination profile…" });
43
+ const shown = await runner(
44
+ cliPath,
45
+ ["profile", "show", profileName.trim(), ...client],
46
+ environment,
47
+ runnerPath,
48
+ { signal, timeoutMs: Math.min(timeoutMs, 60_000) },
49
+ );
50
+ throwForInterruptedRun(shown, timeoutMs);
51
+ const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
52
+ if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
53
+
54
+ const capture = [
55
+ "profile", operation,
56
+ "--browser", browser,
57
+ "--profile", localProfile,
58
+ "--name", profileName.trim(),
59
+ ...client,
60
+ "--accept-terms",
61
+ "--auto-fit",
62
+ ];
63
+ for (const domain of onlyDomains) capture.push("--only-domain", domain);
64
+ const parseProgress = progressParser(onProgress);
65
+ const result = await runner(cliPath, capture, environment, runnerPath, {
66
+ signal,
67
+ timeoutMs,
68
+ onOutput: parseProgress,
69
+ });
70
+ parseProgress("\n");
71
+ throwForInterruptedRun(result, timeoutMs);
72
+ if (result.status !== 0) throw new Error(actionableFailure(result.output));
73
+
74
+ onProgress({ phase: "verifying", fraction: 0.97, detail: "Verifying the cloud profile…" });
75
+ const verified = await runner(
76
+ cliPath,
77
+ ["profile", "show", profileName.trim(), ...client],
78
+ environment,
79
+ runnerPath,
80
+ { signal, timeoutMs: Math.min(timeoutMs, 60_000) },
81
+ );
82
+ throwForInterruptedRun(verified, timeoutMs);
83
+ if (verified.status !== 0) {
84
+ throw new Error(`The upload finished, but Browserless could not verify the cloud profile: ${lastLine(verified.output) || "profile lookup failed"}`);
85
+ }
86
+
87
+ const details = parseJSON(result.output);
88
+ const cookies = details?.cookieCount;
89
+ const origins = details?.originCount;
90
+ const counts = Number.isInteger(cookies) && Number.isInteger(origins)
91
+ ? ` (${cookies} cookies, ${origins} origins)`
92
+ : "";
93
+ return {
94
+ operation,
95
+ verified: true,
96
+ profileName: details?.name || profileName.trim(),
97
+ cookieCount: cookies,
98
+ originCount: origins,
99
+ droppedOriginCount: droppedOrigins(result.output),
100
+ failedOriginCount: failedOrigins(result.output),
101
+ summary: uploadSummary({ operation, details, fallbackName: profileName.trim(), counts, output: result.output }),
102
+ };
103
+ }
104
+
105
+ export function runBrowserlessCLI(cliPath, args, environment, runnerPath, {
106
+ signal,
107
+ timeoutMs = 15 * 60 * 1000,
108
+ onOutput = () => {},
109
+ } = {}) {
110
+ return new Promise((resolve, reject) => {
111
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-cookie-bridge-browserless-"));
112
+ fs.chmodSync(temporaryRoot, 0o700);
113
+ const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
114
+ env: { ...environment, TMPDIR: temporaryRoot },
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ detached: process.platform !== "win32",
117
+ });
118
+ let stdout = "";
119
+ let stderr = "";
120
+ let interruption = null;
121
+ let settled = false;
122
+ const append = (target, chunk) => {
123
+ const text = String(chunk);
124
+ if (target === "stdout") stdout += text;
125
+ else stderr += text;
126
+ onOutput(text);
127
+ };
128
+ const terminate = (reason) => {
129
+ if (settled || interruption) return;
130
+ interruption = reason;
131
+ try {
132
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM");
133
+ else child.kill("SIGTERM");
134
+ } catch {}
135
+ setTimeout(() => {
136
+ if (child.exitCode !== null || child.signalCode !== null) return;
137
+ try {
138
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
139
+ else child.kill("SIGKILL");
140
+ } catch {}
141
+ }, 2_000).unref();
142
+ };
143
+ const abort = () => terminate("canceled");
144
+ signal?.addEventListener("abort", abort, { once: true });
145
+ if (signal?.aborted) abort();
146
+ const timer = setTimeout(() => terminate("timedOut"), timeoutMs);
147
+ timer.unref();
148
+ child.stdout.on("data", (chunk) => append("stdout", chunk));
149
+ child.stderr.on("data", (chunk) => append("stderr", chunk));
150
+ child.on("error", (error) => {
151
+ settled = true;
152
+ clearTimeout(timer);
153
+ signal?.removeEventListener("abort", abort);
154
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
155
+ reject(error);
156
+ });
157
+ child.on("close", (status) => {
158
+ settled = true;
159
+ clearTimeout(timer);
160
+ signal?.removeEventListener("abort", abort);
161
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
162
+ resolve({
163
+ status: interruption === "canceled" ? 130 : interruption === "timedOut" ? 124 : (status ?? 1),
164
+ output: `${stderr}${stdout}`,
165
+ canceled: interruption === "canceled",
166
+ timedOut: interruption === "timedOut",
167
+ });
168
+ });
169
+ });
170
+ }
171
+
172
+ export function progressParser(onProgress) {
173
+ let pending = "";
174
+ let lastPhase = "";
175
+ return (chunk) => {
176
+ pending += chunk;
177
+ const lines = pending.split(/\r\n|\n|\r/);
178
+ pending = lines.pop() ?? "";
179
+ for (const raw of lines) {
180
+ const line = raw.trim();
181
+ let progress = null;
182
+ if (/copying profile data/i.test(line)) {
183
+ progress = { phase: "copying", fraction: 0.18, detail: "Copying profile data into an isolated workspace…" };
184
+ } else if (/launching headless browser/i.test(line)) {
185
+ progress = { phase: "launching", fraction: 0.30, detail: "Launching the temporary browser…" };
186
+ } else if (/waiting for browser to be ready/i.test(line)) {
187
+ progress = { phase: "waiting", fraction: 0.38, detail: "Waiting for the temporary browser…" };
188
+ } else if (/capturing per-origin storage/i.test(line)) {
189
+ progress = { phase: "capturing", fraction: 0.42, detail: "Capturing cookies, local storage, and IndexedDB…" };
190
+ } else if (/^\d+\/\d+\s+/.test(line)) {
191
+ const match = line.match(/^(\d+)\/(\d+)\s+(.+)$/);
192
+ if (match) {
193
+ const current = Number(match[1]);
194
+ const total = Number(match[2]);
195
+ progress = {
196
+ phase: "capturing",
197
+ fraction: total > 0 ? 0.42 + 0.42 * Math.min(current / total, 1) : 0.42,
198
+ current,
199
+ total,
200
+ detail: `Capturing ${match[3]}`,
201
+ };
202
+ }
203
+ } else if (/uploading to browserless/i.test(line)) {
204
+ progress = { phase: "uploading", fraction: 0.90, detail: "Uploading the fitted authenticated profile…" };
205
+ }
206
+ if (!progress) continue;
207
+ const identity = `${progress.phase}:${progress.current ?? ""}:${progress.total ?? ""}:${progress.detail}`;
208
+ if (identity === lastPhase) continue;
209
+ lastPhase = identity;
210
+ onProgress(progress);
211
+ }
212
+ };
213
+ }
214
+
215
+ function isMissingProfile(output) {
216
+ return /(?:not found|does not exist|404)/i.test(output);
217
+ }
218
+
219
+ function parseJSON(output) {
220
+ const start = output.indexOf("{");
221
+ const end = output.lastIndexOf("}");
222
+ if (start !== -1 && end > start) {
223
+ try {
224
+ const parsed = JSON.parse(output.slice(start, end + 1));
225
+ if (parsed && typeof parsed === "object") return parsed;
226
+ } catch {}
227
+ }
228
+ for (const line of output.split("\n").reverse()) {
229
+ try {
230
+ const parsed = JSON.parse(line);
231
+ if (parsed && typeof parsed === "object") return parsed;
232
+ } catch {}
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function lastLine(output) {
238
+ return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
239
+ }
240
+
241
+ function throwForInterruptedRun(result, timeoutMs) {
242
+ if (result?.canceled || result?.status === 130) {
243
+ throw new Error("Browserless upload canceled. Temporary profile data was removed.");
244
+ }
245
+ if (result?.timedOut || result?.status === 124) {
246
+ const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
247
+ throw new Error(`Browserless upload timed out after ${minutes} minute${minutes === 1 ? "" : "s"}. Check the connection, close the source browser, and try again with a smaller domain allowlist.`);
248
+ }
249
+ }
250
+
251
+ function droppedOrigins(output) {
252
+ return Number(output.match(/--auto-fit:\s*dropped\s+(\d+)\s+origin/i)?.[1] || 0);
253
+ }
254
+
255
+ function failedOrigins(output) {
256
+ return Number(output.match(/!\s+(\d+)\s+origin\(s\) failed to capture/i)?.[1] || 0);
257
+ }
258
+
259
+ function uploadSummary({ operation, details, fallbackName, counts, output }) {
260
+ const dropped = droppedOrigins(output);
261
+ const failed = failedOrigins(output);
262
+ const warnings = [];
263
+ if (dropped > 0) warnings.push(`${dropped} heavy origin${dropped === 1 ? "" : "s"} omitted to fit Browserless's 2 MB cap`);
264
+ if (failed > 0) warnings.push(`${failed} origin${failed === 1 ? "" : "s"} could not be captured`);
265
+ const warning = warnings.length > 0 ? `; ${warnings.join("; ")}` : "";
266
+ return `Browserless profile ${operation === "refresh" ? "updated" : "created"} and verified: ${details?.name || fallbackName}${counts}${warning}`;
267
+ }
268
+
269
+ function actionableFailure(output) {
270
+ const detail = lastLine(output) || "Browserless profile upload failed.";
271
+ if (/(?:failed to reach|enotfound|econnreset|econnrefused|network|socket hang up|fetch failed)/i.test(output)) {
272
+ return `Browserless could not be reached. Check your internet connection and region, then try again. ${detail}`;
273
+ }
274
+ if (/(?:profile busy|singletonlock|browser.*running|source browser must be closed)/i.test(output)) {
275
+ return `The source browser is still using this profile. Quit it completely, wait a few seconds, and try again. ${detail}`;
276
+ }
277
+ if (/(?:2 MB|too large|artifact.*cap|payload.*large)/i.test(output)) {
278
+ return `The captured state could not fit Browserless's 2 MB profile cap. Add a domain allowlist for the sites you need, then try again. ${detail}`;
279
+ }
280
+ return detail;
281
+ }
package/src/cli.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  isCodexRunning,
10
10
  } from "./codex-direct-import.js";
11
11
  import { readChromiumProfile } from "./chromium-reader.js";
12
+ import { uploadBrowserlessProfile } from "./browserless.js";
13
+ import { inspectBrowserlessProfile } from "./browserless-preflight.js";
12
14
  import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
13
15
  import {
14
16
  braveCookiePaths,
@@ -44,7 +46,8 @@ Commands:
44
46
  install-app [--no-open]
45
47
  bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
46
48
  preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on
47
- sync [--timeout 300]
49
+ sync [--timeout 300] [--allow-cloud-upload]
50
+ browserless-preflight
48
51
  doctor
49
52
  enable-login-sync
50
53
  disable-login-sync
@@ -54,13 +57,15 @@ Commands:
54
57
  help
55
58
  `;
56
59
 
57
- export async function main(argv) {
60
+ export async function main(argv, { signal } = {}) {
58
61
  const [command = "help", ...args] = argv;
59
62
  switch (command) {
60
63
  case "setup":
61
64
  return setup(args);
62
65
  case "sync":
63
- return sync(args);
66
+ return sync(args, { signal });
67
+ case "browserless-preflight":
68
+ return browserlessPreflight();
64
69
  case "install-app":
65
70
  return installDesktopApp(args);
66
71
  case "bootstrap-bundled":
@@ -123,6 +128,9 @@ function preferences(args) {
123
128
  menuBar: booleanFlag(args, "--menu-bar", existing.ui?.menuBar === true),
124
129
  openAtLogin: booleanFlag(args, "--open-at-login", existing.ui?.openAtLogin !== false),
125
130
  autoCheckUpdates: booleanFlag(args, "--auto-check-updates", existing.ui?.autoCheckUpdates !== false),
131
+ browserlessProfileName: stringFlag(args, "--browserless-profile", existing.browserless?.profileName || "browser-cookie-bridge"),
132
+ browserlessRegion: stringFlag(args, "--browserless-region", existing.browserless?.region || "sfo"),
133
+ browserlessOnlyDomains: optionalStringFlag(args, "--browserless-domains", (existing.browserless?.onlyDomains || []).join(",")),
126
134
  });
127
135
  console.log(
128
136
  `Saved: source=${config.sourceBrowser}, target=${config.targetBrowser}, cookies=${config.imports.cookies ? "on" : "off"}, history=${config.imports.history ? "on" : "off"}, menu-bar=${config.ui.menuBar ? "on" : "off"}, open-at-login=${config.ui.openAtLogin ? "on" : "off"}, auto-check-updates=${config.ui.autoCheckUpdates ? "on" : "off"}`,
@@ -239,6 +247,10 @@ function setup(args) {
239
247
  if (config.targetBrowser === "codex") {
240
248
  console.log(`Source browser: ${config.sourceBrowser} (read locally; no extension required)`);
241
249
  console.log("Target integration: direct local Codex merge (Codex must be closed)");
250
+ } else if (config.targetBrowser === "browserless") {
251
+ console.log(`Source browser: ${config.sourceBrowser} (captured locally by the Browserless CLI)`);
252
+ console.log(`Cloud destination: Browserless ${config.browserless?.region || "sfo"} / ${config.browserless?.profileName || "browser-cookie-bridge"}`);
253
+ console.log("Browserless uploads are manual-only and require --allow-cloud-upload.");
242
254
  } else {
243
255
  console.log(`Source extension (${config.sourceBrowser}): ${installedExtensionDir(undefined, config.sourceBrowser)}`);
244
256
  console.log(`Target extension (${config.targetBrowser}): ${installedExtensionDir(undefined, config.targetBrowser)}`);
@@ -251,11 +263,42 @@ function setup(args) {
251
263
  : "Cookie values are transferred in memory and are not written to logs or disk.");
252
264
  }
253
265
 
254
- async function sync(args) {
266
+ async function sync(args, { signal } = {}) {
255
267
  assertMacOS();
256
- const seconds = integerFlag(args, "--timeout", 300, 5, 3600);
257
268
  const config = readConfig();
258
- const isCodexTarget = (config.targetBrowser || "codex") === "codex";
269
+ const target = config.targetBrowser || "codex";
270
+ const seconds = integerFlag(args, "--timeout", target === "browserless" ? 900 : 300, 5, 3600);
271
+ const isCodexTarget = target === "codex";
272
+ if (target === "browserless") {
273
+ if (!args.includes("--allow-cloud-upload")) {
274
+ throw new Error("Browserless cloud uploads are manual-only. Start one from the app or pass --allow-cloud-upload explicitly.");
275
+ }
276
+ const source = config.sourceBrowser || "brave";
277
+ const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
278
+ emitBrowserlessProgress({ phase: "preflight", fraction: 0.03, detail: "Inspecting the local profile…" });
279
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
280
+ emitBrowserlessProgress({
281
+ phase: "preflight-complete",
282
+ fraction: 0.06,
283
+ detail: assessment.summary,
284
+ assessment,
285
+ });
286
+ console.log(`Profile preflight: ${assessment.summary}`);
287
+ console.log(`Preparing ${source} profile ${local.profileName} for an explicit Browserless cloud upload…`);
288
+ const result = await uploadBrowserlessProfile({
289
+ browser: source,
290
+ localProfile: local.profileName,
291
+ profileName: config.browserless?.profileName || "browser-cookie-bridge",
292
+ region: config.browserless?.region || "sfo",
293
+ onlyDomains: config.browserless?.onlyDomains || [],
294
+ timeoutMs: seconds * 1000,
295
+ signal,
296
+ onProgress: emitBrowserlessProgress,
297
+ });
298
+ emitBrowserlessProgress({ phase: "complete", fraction: 1, detail: result.summary });
299
+ console.log(result.summary);
300
+ return result;
301
+ }
259
302
  if (isCodexTarget) {
260
303
  if (isCodexRunning()) throw new Error(CODEX_RUNNING_ERROR);
261
304
  const payload = readChromiumProfile({
@@ -296,6 +339,21 @@ async function sync(args) {
296
339
  return result;
297
340
  }
298
341
 
342
+ function browserlessPreflight() {
343
+ assertMacOS();
344
+ const config = readConfig();
345
+ const source = config.sourceBrowser || "brave";
346
+ if (source === "comet") throw new Error("Comet is not supported by Browserless profile capture yet.");
347
+ const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
348
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
349
+ console.log(JSON.stringify({ browser: source, profileName: local.profileName, ...assessment }));
350
+ return assessment;
351
+ }
352
+
353
+ function emitBrowserlessProgress(event) {
354
+ console.log(`BCB_PROGRESS ${JSON.stringify(event)}`);
355
+ }
356
+
299
357
  export function directCodexSummary(result) {
300
358
  const imported = result.imported + result.historyImported;
301
359
  const skipped = result.skipped + result.historySkipped;
@@ -331,6 +389,8 @@ function doctor() {
331
389
  console.log(
332
390
  target === "codex"
333
391
  ? "Target integration: direct local Codex merge (Codex must be closed)"
392
+ : target === "browserless"
393
+ ? `Target integration: optional Browserless cloud upload (${config?.browserless?.region || "sfo"}); manual only`
334
394
  : `Target extension: ${status(installedExtensionDir(home, target))}`,
335
395
  );
336
396
  console.log(`Daily schedule: ${status(launchAgentPath(home))}`);
@@ -355,6 +415,8 @@ function enableLoginSync() {
355
415
  const config = readConfig();
356
416
  console.log(config.targetBrowser === "codex"
357
417
  ? "A sync starts when you sign in and updates Codex only when Codex is closed."
418
+ : config.targetBrowser === "browserless"
419
+ ? "Browserless uploads remain manual-only; login sync will not send data to the cloud."
358
420
  : "A sync starts when you sign in and waits up to five minutes for both browser extensions.");
359
421
  }
360
422
 
@@ -373,6 +435,9 @@ function setAppLogin(enabled) {
373
435
  menuBar: existing.ui?.menuBar === true,
374
436
  openAtLogin: enabled,
375
437
  autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
438
+ browserlessProfileName: existing.browserless?.profileName,
439
+ browserlessRegion: existing.browserless?.region,
440
+ browserlessOnlyDomains: existing.browserless?.onlyDomains,
376
441
  });
377
442
  if (enabled) {
378
443
  const appPath = installedAppPath();
@@ -422,6 +487,11 @@ function stringFlag(args, name, fallback) {
422
487
  return value;
423
488
  }
424
489
 
490
+ function optionalStringFlag(args, name, fallback) {
491
+ const index = args.indexOf(name);
492
+ return index === -1 ? fallback : (args[index + 1] ?? "");
493
+ }
494
+
425
495
  function existing(paths) {
426
496
  return paths.filter((candidate) => fs.existsSync(candidate));
427
497
  }