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/README.md +35 -13
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/bin/brave-codex-cookie-sync.js +10 -2
- package/extension-template/manifest.json +1 -1
- package/macos-app/Info.plist +2 -2
- package/macos-app/Resources/BrowserIcons/browserless.svg +6 -0
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +342 -62
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +400 -34
- package/package.json +11 -6
- package/src/browserless-preflight.js +113 -0
- package/src/browserless-runner.js +13 -0
- package/src/browserless.js +281 -0
- package/src/cli.js +76 -6
- package/src/config.js +38 -4
- package/src/paths.js +1 -1
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-cookie-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
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": ">=
|
|
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
|
|
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
|
}
|