browser-cookie-bridge 1.2.0 → 1.4.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.
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
4
  import { spawn } from "node:child_process";
3
5
  import { projectRoot } from "./paths.js";
@@ -13,7 +15,10 @@ export async function uploadBrowserlessProfile({
13
15
  onlyDomains = [],
14
16
  token = process.env.BROWSERLESS_TOKEN,
15
17
  root = projectRoot(),
16
- runner = runCLI,
18
+ runner = runBrowserlessCLI,
19
+ signal,
20
+ timeoutMs = 15 * 60 * 1000,
21
+ onProgress = () => {},
17
22
  } = {}) {
18
23
  if (!SUPPORTED_SOURCES.has(browser)) {
19
24
  throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
@@ -34,7 +39,15 @@ export async function uploadBrowserlessProfile({
34
39
  DO_NOT_TRACK: "1",
35
40
  };
36
41
  const client = ["--region", region, "--json"];
37
- const shown = await runner(cliPath, ["profile", "show", profileName.trim(), ...client], environment, runnerPath);
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);
38
51
  const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
39
52
  if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
40
53
 
@@ -48,8 +61,28 @@ export async function uploadBrowserlessProfile({
48
61
  "--auto-fit",
49
62
  ];
50
63
  for (const domain of onlyDomains) capture.push("--only-domain", domain);
51
- const result = await runner(cliPath, capture, environment, runnerPath);
52
- if (result.status !== 0) throw new Error(lastLine(result.output) || "Browserless profile upload failed.");
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
+ }
53
86
 
54
87
  const details = parseJSON(result.output);
55
88
  const cookies = details?.cookieCount;
@@ -59,28 +92,126 @@ export async function uploadBrowserlessProfile({
59
92
  : "";
60
93
  return {
61
94
  operation,
95
+ verified: true,
62
96
  profileName: details?.name || profileName.trim(),
63
97
  cookieCount: cookies,
64
98
  originCount: origins,
65
- summary: `Browserless profile ${operation === "refresh" ? "updated" : "created"}: ${details?.name || profileName.trim()}${counts}`,
99
+ droppedOriginCount: droppedOrigins(result.output),
100
+ failedOriginCount: failedOrigins(result.output),
101
+ summary: uploadSummary({ operation, details, fallbackName: profileName.trim(), counts, output: result.output }),
66
102
  };
67
103
  }
68
104
 
69
- function runCLI(cliPath, args, environment, runnerPath) {
105
+ export function runBrowserlessCLI(cliPath, args, environment, runnerPath, {
106
+ signal,
107
+ timeoutMs = 15 * 60 * 1000,
108
+ onOutput = () => {},
109
+ } = {}) {
70
110
  return new Promise((resolve, reject) => {
111
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-cookie-bridge-browserless-"));
112
+ fs.chmodSync(temporaryRoot, 0o700);
71
113
  const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
72
- env: environment,
114
+ env: { ...environment, TMPDIR: temporaryRoot },
73
115
  stdio: ["ignore", "pipe", "pipe"],
116
+ detached: process.platform !== "win32",
74
117
  });
75
118
  let stdout = "";
76
119
  let stderr = "";
77
- child.stdout.on("data", (chunk) => { stdout += chunk; });
78
- child.stderr.on("data", (chunk) => { stderr += chunk; });
79
- child.on("error", reject);
80
- child.on("close", (status) => resolve({ status: status ?? 1, output: `${stderr}${stdout}` }));
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
+ });
81
169
  });
82
170
  }
83
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
+
84
215
  function isMissingProfile(output) {
85
216
  return /(?:not found|does not exist|404)/i.test(output);
86
217
  }
@@ -106,3 +237,45 @@ function parseJSON(output) {
106
237
  function lastLine(output) {
107
238
  return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
108
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
@@ -10,6 +10,7 @@ import {
10
10
  } from "./codex-direct-import.js";
11
11
  import { readChromiumProfile } from "./chromium-reader.js";
12
12
  import { uploadBrowserlessProfile } from "./browserless.js";
13
+ import { inspectBrowserlessProfile } from "./browserless-preflight.js";
13
14
  import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
14
15
  import {
15
16
  braveCookiePaths,
@@ -44,8 +45,9 @@ Commands:
44
45
  setup [--hour 9] [--minute 0] [--no-schedule]
45
46
  install-app [--no-open]
46
47
  bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
47
- preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on
48
+ preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on --auto-restart-codex off
48
49
  sync [--timeout 300] [--allow-cloud-upload]
50
+ browserless-preflight
49
51
  doctor
50
52
  enable-login-sync
51
53
  disable-login-sync
@@ -55,13 +57,15 @@ Commands:
55
57
  help
56
58
  `;
57
59
 
58
- export async function main(argv) {
60
+ export async function main(argv, { signal } = {}) {
59
61
  const [command = "help", ...args] = argv;
60
62
  switch (command) {
61
63
  case "setup":
62
64
  return setup(args);
63
65
  case "sync":
64
- return sync(args);
66
+ return sync(args, { signal });
67
+ case "browserless-preflight":
68
+ return browserlessPreflight();
65
69
  case "install-app":
66
70
  return installDesktopApp(args);
67
71
  case "bootstrap-bundled":
@@ -124,12 +128,13 @@ function preferences(args) {
124
128
  menuBar: booleanFlag(args, "--menu-bar", existing.ui?.menuBar === true),
125
129
  openAtLogin: booleanFlag(args, "--open-at-login", existing.ui?.openAtLogin !== false),
126
130
  autoCheckUpdates: booleanFlag(args, "--auto-check-updates", existing.ui?.autoCheckUpdates !== false),
131
+ autoRestartCodex: booleanFlag(args, "--auto-restart-codex", existing.ui?.autoRestartCodex === true),
127
132
  browserlessProfileName: stringFlag(args, "--browserless-profile", existing.browserless?.profileName || "browser-cookie-bridge"),
128
133
  browserlessRegion: stringFlag(args, "--browserless-region", existing.browserless?.region || "sfo"),
129
134
  browserlessOnlyDomains: optionalStringFlag(args, "--browserless-domains", (existing.browserless?.onlyDomains || []).join(",")),
130
135
  });
131
136
  console.log(
132
- `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"}`,
137
+ `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"}, auto-restart-codex=${config.ui.autoRestartCodex ? "on" : "off"}`,
133
138
  );
134
139
  }
135
140
 
@@ -259,11 +264,11 @@ function setup(args) {
259
264
  : "Cookie values are transferred in memory and are not written to logs or disk.");
260
265
  }
261
266
 
262
- async function sync(args) {
267
+ async function sync(args, { signal } = {}) {
263
268
  assertMacOS();
264
- const seconds = integerFlag(args, "--timeout", 300, 5, 3600);
265
269
  const config = readConfig();
266
270
  const target = config.targetBrowser || "codex";
271
+ const seconds = integerFlag(args, "--timeout", target === "browserless" ? 900 : 300, 5, 3600);
267
272
  const isCodexTarget = target === "codex";
268
273
  if (target === "browserless") {
269
274
  if (!args.includes("--allow-cloud-upload")) {
@@ -271,6 +276,15 @@ async function sync(args) {
271
276
  }
272
277
  const source = config.sourceBrowser || "brave";
273
278
  const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
279
+ emitBrowserlessProgress({ phase: "preflight", fraction: 0.03, detail: "Inspecting the local profile…" });
280
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
281
+ emitBrowserlessProgress({
282
+ phase: "preflight-complete",
283
+ fraction: 0.06,
284
+ detail: assessment.summary,
285
+ assessment,
286
+ });
287
+ console.log(`Profile preflight: ${assessment.summary}`);
274
288
  console.log(`Preparing ${source} profile ${local.profileName} for an explicit Browserless cloud upload…`);
275
289
  const result = await uploadBrowserlessProfile({
276
290
  browser: source,
@@ -278,7 +292,11 @@ async function sync(args) {
278
292
  profileName: config.browserless?.profileName || "browser-cookie-bridge",
279
293
  region: config.browserless?.region || "sfo",
280
294
  onlyDomains: config.browserless?.onlyDomains || [],
295
+ timeoutMs: seconds * 1000,
296
+ signal,
297
+ onProgress: emitBrowserlessProgress,
281
298
  });
299
+ emitBrowserlessProgress({ phase: "complete", fraction: 1, detail: result.summary });
282
300
  console.log(result.summary);
283
301
  return result;
284
302
  }
@@ -322,6 +340,21 @@ async function sync(args) {
322
340
  return result;
323
341
  }
324
342
 
343
+ function browserlessPreflight() {
344
+ assertMacOS();
345
+ const config = readConfig();
346
+ const source = config.sourceBrowser || "brave";
347
+ if (source === "comet") throw new Error("Comet is not supported by Browserless profile capture yet.");
348
+ const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
349
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
350
+ console.log(JSON.stringify({ browser: source, profileName: local.profileName, ...assessment }));
351
+ return assessment;
352
+ }
353
+
354
+ function emitBrowserlessProgress(event) {
355
+ console.log(`BCB_PROGRESS ${JSON.stringify(event)}`);
356
+ }
357
+
325
358
  export function directCodexSummary(result) {
326
359
  const imported = result.imported + result.historyImported;
327
360
  const skipped = result.skipped + result.historySkipped;
@@ -403,6 +436,7 @@ function setAppLogin(enabled) {
403
436
  menuBar: existing.ui?.menuBar === true,
404
437
  openAtLogin: enabled,
405
438
  autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
439
+ autoRestartCodex: existing.ui?.autoRestartCodex === true,
406
440
  browserlessProfileName: existing.browserless?.profileName,
407
441
  browserlessRegion: existing.browserless?.region,
408
442
  browserlessOnlyDomains: existing.browserless?.onlyDomains,
package/src/config.js CHANGED
@@ -50,6 +50,7 @@ export function installConfig({ home, hour = 9, minute = 0, nodePath = process.e
50
50
  menuBar: existing.ui?.menuBar !== false,
51
51
  openAtLogin: existing.ui?.openAtLogin !== false,
52
52
  autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
53
+ autoRestartCodex: existing.ui?.autoRestartCodex === true,
53
54
  },
54
55
  browserless: {
55
56
  profileName: cleanProfileName(existing.browserless?.profileName) || "browser-cookie-bridge",
@@ -77,6 +78,7 @@ export function updatePreferences({
77
78
  menuBar,
78
79
  openAtLogin,
79
80
  autoCheckUpdates,
81
+ autoRestartCodex,
80
82
  browserlessProfileName,
81
83
  browserlessRegion,
82
84
  browserlessOnlyDomains,
@@ -98,6 +100,7 @@ export function updatePreferences({
98
100
  menuBar: Boolean(menuBar),
99
101
  openAtLogin: Boolean(openAtLogin),
100
102
  autoCheckUpdates: Boolean(autoCheckUpdates),
103
+ autoRestartCodex: Boolean(autoRestartCodex),
101
104
  };
102
105
  const region = browserlessRegion ?? config.browserless?.region ?? "sfo";
103
106
  if (!["sfo", "lon", "ams"].includes(region)) throw new Error("Browserless region must be sfo, lon, or ams");