camoufox-cli 0.4.0__tar.gz → 0.6.0__tar.gz

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.

Potentially problematic release.


This version of camoufox-cli might be problematic. Click here for more details.

Files changed (118) hide show
  1. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/.claude-plugin/marketplace.json +20 -0
  2. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/README.md +31 -0
  3. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/package-lock.json +2 -2
  4. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/package.json +1 -1
  5. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/browser.ts +12 -0
  6. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/cli.ts +27 -12
  7. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/js/src/config.ts +140 -0
  8. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/js/src/install.ts +126 -0
  9. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/cli.test.ts +156 -1
  10. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/js/tests/install.test.ts +153 -0
  11. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/pyproject.toml +1 -1
  12. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/skills/camoufox-cli/SKILL.md +9 -0
  13. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/skills/camoufox-cli/references/commands.md +11 -0
  14. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/browser.py +13 -0
  15. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/cli.py +32 -15
  16. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/src/camoufox_cli/config.py +130 -0
  17. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/src/camoufox_cli/install.py +127 -0
  18. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_cli.py +135 -0
  19. camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6/tests/test_install.py +150 -0
  20. camoufox_cli-0.6.0/.gitignore +30 -0
  21. camoufox_cli-0.6.0/LICENSE +21 -0
  22. camoufox_cli-0.6.0/Makefile +30 -0
  23. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0}/PKG-INFO +32 -1
  24. camoufox_cli-0.6.0/README.md +257 -0
  25. camoufox_cli-0.6.0/js/.gitignore +2 -0
  26. camoufox_cli-0.6.0/js/LICENSE +21 -0
  27. camoufox_cli-0.6.0/js/package-lock.json +2719 -0
  28. camoufox_cli-0.6.0/js/package.json +47 -0
  29. camoufox_cli-0.6.0/js/pnpm-lock.yaml +1599 -0
  30. camoufox_cli-0.6.0/js/scripts/postinstall.js +11 -0
  31. camoufox_cli-0.6.0/js/src/browser.ts +199 -0
  32. camoufox_cli-0.6.0/js/src/cli.ts +506 -0
  33. camoufox_cli-0.6.0/js/src/commands.ts +352 -0
  34. camoufox_cli-0.6.0/js/src/config.ts +140 -0
  35. camoufox_cli-0.6.0/js/src/daemon.ts +27 -0
  36. camoufox_cli-0.6.0/js/src/identity.ts +206 -0
  37. camoufox_cli-0.6.0/js/src/install.ts +126 -0
  38. camoufox_cli-0.6.0/js/src/protocol.ts +32 -0
  39. camoufox_cli-0.6.0/js/src/proxy.ts +40 -0
  40. camoufox_cli-0.6.0/js/src/refs.ts +65 -0
  41. camoufox_cli-0.6.0/js/src/server.ts +131 -0
  42. camoufox_cli-0.6.0/js/tests/browser.test.ts +61 -0
  43. camoufox_cli-0.6.0/js/tests/cli.test.ts +455 -0
  44. camoufox_cli-0.6.0/js/tests/commands.test.ts +194 -0
  45. camoufox_cli-0.6.0/js/tests/e2e.test.ts +216 -0
  46. camoufox_cli-0.6.0/js/tests/install.test.ts +153 -0
  47. camoufox_cli-0.6.0/js/tests/protocol.test.ts +98 -0
  48. camoufox_cli-0.6.0/js/tests/proxy.test.ts +55 -0
  49. camoufox_cli-0.6.0/js/tests/refs.test.ts +150 -0
  50. camoufox_cli-0.6.0/js/tests/server.test.ts +193 -0
  51. camoufox_cli-0.6.0/js/tsconfig.json +14 -0
  52. camoufox_cli-0.6.0/js/vitest.config.ts +7 -0
  53. camoufox_cli-0.6.0/pyproject.toml +46 -0
  54. camoufox_cli-0.6.0/skills/camoufox-cli/SKILL.md +351 -0
  55. camoufox_cli-0.6.0/skills/camoufox-cli/references/commands.md +143 -0
  56. camoufox_cli-0.6.0/skills/camoufox-cli/references/snapshot-refs.md +221 -0
  57. camoufox_cli-0.6.0/src/camoufox_cli/__init__.py +0 -0
  58. camoufox_cli-0.6.0/src/camoufox_cli/__main__.py +38 -0
  59. camoufox_cli-0.6.0/src/camoufox_cli/browser.py +197 -0
  60. camoufox_cli-0.6.0/src/camoufox_cli/cli.py +517 -0
  61. camoufox_cli-0.6.0/src/camoufox_cli/commands.py +390 -0
  62. camoufox_cli-0.6.0/src/camoufox_cli/config.py +130 -0
  63. camoufox_cli-0.6.0/src/camoufox_cli/identity.py +215 -0
  64. camoufox_cli-0.6.0/src/camoufox_cli/install.py +127 -0
  65. camoufox_cli-0.6.0/src/camoufox_cli/protocol.py +24 -0
  66. camoufox_cli-0.6.0/src/camoufox_cli/proxy.py +30 -0
  67. camoufox_cli-0.6.0/src/camoufox_cli/refs.py +83 -0
  68. camoufox_cli-0.6.0/src/camoufox_cli/server.py +145 -0
  69. camoufox_cli-0.6.0/tests/__init__.py +0 -0
  70. camoufox_cli-0.6.0/tests/fixture.html +22 -0
  71. camoufox_cli-0.6.0/tests/test_cli.py +392 -0
  72. camoufox_cli-0.6.0/tests/test_commands.py +556 -0
  73. camoufox_cli-0.6.0/tests/test_e2e.py +217 -0
  74. camoufox_cli-0.6.0/tests/test_install.py +150 -0
  75. camoufox_cli-0.6.0/tests/test_protocol.py +63 -0
  76. camoufox_cli-0.6.0/tests/test_proxy.py +37 -0
  77. camoufox_cli-0.6.0/tests/test_refs.py +112 -0
  78. camoufox_cli-0.4.0/.claude/settings.local.json +0 -20
  79. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/.gitignore +0 -0
  80. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/LICENSE +0 -0
  81. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/Makefile +0 -0
  82. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/.gitignore +0 -0
  83. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/LICENSE +0 -0
  84. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/pnpm-lock.yaml +0 -0
  85. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/scripts/postinstall.js +0 -0
  86. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/commands.ts +0 -0
  87. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/daemon.ts +0 -0
  88. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/identity.ts +0 -0
  89. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/protocol.ts +0 -0
  90. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/proxy.ts +0 -0
  91. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/refs.ts +0 -0
  92. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/src/server.ts +0 -0
  93. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/browser.test.ts +0 -0
  94. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/commands.test.ts +0 -0
  95. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/e2e.test.ts +0 -0
  96. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/protocol.test.ts +0 -0
  97. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/proxy.test.ts +0 -0
  98. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/refs.test.ts +0 -0
  99. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tests/server.test.ts +0 -0
  100. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/tsconfig.json +0 -0
  101. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/js/vitest.config.ts +0 -0
  102. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/skills/camoufox-cli/references/snapshot-refs.md +0 -0
  103. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/__init__.py +0 -0
  104. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/__main__.py +0 -0
  105. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/commands.py +0 -0
  106. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/identity.py +0 -0
  107. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/protocol.py +0 -0
  108. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/proxy.py +0 -0
  109. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/refs.py +0 -0
  110. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/src/camoufox_cli/server.py +0 -0
  111. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/__init__.py +0 -0
  112. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/fixture.html +0 -0
  113. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_commands.py +0 -0
  114. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_e2e.py +0 -0
  115. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_protocol.py +0 -0
  116. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_proxy.py +0 -0
  117. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0/.claude/worktrees/busy-darwin-6b67a6}/tests/test_refs.py +0 -0
  118. {camoufox_cli-0.4.0 → camoufox_cli-0.6.0}/.claude-plugin/marketplace.json +0 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "camoufox-cli",
3
+ "owner": {
4
+ "name": "Benn Huang"
5
+ },
6
+ "metadata": {
7
+ "description": "Anti-detect browser automation CLI & Skills for AI agents, powered by Camoufox",
8
+ "version": "0.1.0"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "camoufox-cli",
13
+ "description": "Anti-detect browser automation skill with fingerprint spoofing for AI agents",
14
+ "source": "./",
15
+ "skills": [
16
+ "./skills/camoufox-cli"
17
+ ]
18
+ }
19
+ ]
20
+ }
@@ -40,6 +40,10 @@ On Linux, install system dependencies with:
40
40
  camoufox-cli install --with-deps
41
41
  ```
42
42
 
43
+ The browser is downloaded from GitHub releases. If the anonymous GitHub API
44
+ rate limit is exhausted (common on shared server/CI IPs), the installer
45
+ automatically falls back to scraping github.com release pages.
46
+
43
47
  ### Agent Skill
44
48
 
45
49
  ```bash
@@ -158,6 +162,33 @@ camoufox-cli cookies export file.json # Export cookies
158
162
  --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")
159
163
  ```
160
164
 
165
+ ## Config File
166
+
167
+ Tired of repeating the same flags on every call? Put defaults in `~/.camoufox-cli/config.json` (override the path with `$CAMOUFOX_CLI_CONFIG`):
168
+
169
+ ```json
170
+ {
171
+ "default": {
172
+ "persistent": true,
173
+ "timeout": 3600,
174
+ "proxy": "http://user:pass@host:8080"
175
+ },
176
+ "sessions": {
177
+ "<your-session-name>": {
178
+ "proxy": "socks5://127.0.0.1:1080",
179
+ "locale": "zh-CN"
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ - **`default`** applies to every session.
186
+ - **`sessions` is optional.** You never pre-register sessions — the name is just whatever you pass to `--session`. A `sessions.<name>` block only kicks in when you happen to run `--session <name>`, layering its keys on top of `default`; any other session (or no `--session` at all) just uses `default`. Above, `camoufox-cli --session <your-session-name> open <url>` swaps in that block's proxy + locale, while every other session keeps the `default` proxy.
187
+ - **Command-line flags always win** over the file, which in turn wins over built-in defaults.
188
+ - Settable keys: `proxy`, `locale`, `geoip`, `persistent` (`true` / `false` / path string), `headed`, `timeout`, `json`. `session` is command-line only (it selects which block to apply), and per-command options like `--full` or `-i` are never read from config.
189
+ - A broken config never blocks a command — it's ignored with a warning on stderr.
190
+ - Config is read when a session's daemon **first launches**. If one is already running for that session, run `camoufox-cli --session <name> close` first for changes to take effect.
191
+
161
192
  ## Persistent Identity
162
193
 
163
194
  Without `--persistent`, every launch gets a fresh random fingerprint. With `--persistent`, a `camoufox-cli.json` file is written next to the browser's user data. Fingerprint, OS, and canvas/font seeds are generated on the first launch and frozen afterwards; locale and proxy-derived timezone/geolocation are also saved but track whatever you pass on the command line. Every subsequent launch with the same path reloads the identity, so sites see the same device on every visit.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "camoufox-cli",
3
- "version": "0.1.7",
3
+ "version": "0.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "camoufox-cli",
9
- "version": "0.1.7",
9
+ "version": "0.5.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camoufox-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Anti-detect headless browser automation CLI & Skills for AI agents, powered by Camoufox",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,10 +4,13 @@ import { execFileSync } from "node:child_process";
4
4
  import { mkdirSync } from "node:fs";
5
5
  import { Camoufox, launchOptions } from "camoufox-js";
6
6
  import { firefox, type Browser, type BrowserContext, type Page } from "playwright-core";
7
+ import { ensureMmdb } from "./install.js";
7
8
  import { loadOrCreate, toLaunchOptions } from "./identity.js";
8
9
  import { parseProxySettings } from "./proxy.js";
9
10
  import { RefRegistry } from "./refs.js";
10
11
 
12
+ const MAX_HISTORY = 200;
13
+
11
14
  function ensureBrowserInstalled(): void {
12
15
  try {
13
16
  execFileSync("npx", ["camoufox-js", "path"], { stdio: "pipe" });
@@ -42,6 +45,12 @@ export class BrowserManager {
42
45
 
43
46
  ensureBrowserInstalled();
44
47
 
48
+ if (this.proxy && this.geoip) {
49
+ // Geoip resolution lazily downloads the GeoIP db via the rate-limited
50
+ // GitHub API; fetch it through the resilient path first.
51
+ await ensureMmdb();
52
+ }
53
+
45
54
  const launchOpts: Record<string, unknown> = { headless };
46
55
  let proxySettings: { server: string; username?: string; password?: string } | null = null;
47
56
  if (this.proxy) {
@@ -147,6 +156,9 @@ export class BrowserManager {
147
156
  pushHistory(url: string): void {
148
157
  this.history = this.history.slice(0, this.historyIndex + 1);
149
158
  this.history.push(url);
159
+ if (this.history.length > MAX_HISTORY) {
160
+ this.history = this.history.slice(-MAX_HISTORY);
161
+ }
150
162
  this.historyIndex = this.history.length - 1;
151
163
  }
152
164
 
@@ -7,6 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { execFileSync, spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { loadDefaults } from "./config.js";
10
11
 
11
12
  const SOCKET_PREFIX = "/tmp/camoufox-cli-";
12
13
 
@@ -105,42 +106,46 @@ export interface Flags {
105
106
  }
106
107
 
107
108
  export function parseArgs(argv: string[]): { flags: Flags; command: Record<string, unknown> } {
108
- const flags: Flags = { session: "default", headed: false, timeout: 1800, json: false, persistent: null, proxy: null, geoip: true, locale: null };
109
+ // Flag precedence: command line > config file (per-session block, then the
110
+ // `default` block) > built-in defaults. Only flags explicitly passed on the
111
+ // command line are collected here, so they always win over config.
112
+ const builtin: Flags = { session: "default", headed: false, timeout: 1800, json: false, persistent: null, proxy: null, geoip: true, locale: null };
113
+ const cli: Partial<Flags> = {};
109
114
  const rest: string[] = [];
110
115
 
111
116
  let i = 0;
112
117
  while (i < argv.length) {
113
118
  switch (argv[i]) {
114
119
  case "--session":
115
- flags.session = argv[++i] ?? (process.stderr.write("Error: --session requires a value\n"), process.exit(1), "");
120
+ cli.session = argv[++i] ?? (process.stderr.write("Error: --session requires a value\n"), process.exit(1), "");
116
121
  break;
117
122
  case "--headed":
118
- flags.headed = true;
123
+ cli.headed = true;
119
124
  break;
120
125
  case "--timeout":
121
- flags.timeout = parseInt(argv[++i] ?? "1800", 10);
126
+ cli.timeout = parseInt(argv[++i] ?? "1800", 10);
122
127
  break;
123
128
  case "--json":
124
- flags.json = true;
129
+ cli.json = true;
125
130
  break;
126
131
  case "--persistent": {
127
132
  // Optional value: if next arg looks like a path, use it; otherwise use default
128
133
  const next = argv[i + 1];
129
134
  if (next && (next.includes("/") || next.startsWith(".") || next.startsWith("~"))) {
130
- flags.persistent = argv[++i];
135
+ cli.persistent = argv[++i];
131
136
  } else {
132
- flags.persistent = "";
137
+ cli.persistent = "";
133
138
  }
134
139
  break;
135
140
  }
136
141
  case "--proxy":
137
- flags.proxy = argv[++i] ?? null;
142
+ cli.proxy = argv[++i] ?? null;
138
143
  break;
139
144
  case "--no-geoip":
140
- flags.geoip = false;
145
+ cli.geoip = false;
141
146
  break;
142
147
  case "--locale":
143
- flags.locale = argv[++i] ?? (process.stderr.write("Error: --locale requires a value\n"), process.exit(1), "");
148
+ cli.locale = argv[++i] ?? (process.stderr.write("Error: --locale requires a value\n"), process.exit(1), "");
144
149
  break;
145
150
  default:
146
151
  rest.push(argv[i]);
@@ -153,6 +158,10 @@ export function parseArgs(argv: string[]): { flags: Flags; command: Record<strin
153
158
  process.exit(1);
154
159
  }
155
160
 
161
+ // session selects which config block applies, so it comes only from the CLI.
162
+ const session = cli.session ?? builtin.session;
163
+ const flags: Flags = { ...builtin, ...loadDefaults(session), ...cli };
164
+
156
165
  const command = buildCommand(rest[0], rest);
157
166
  return { flags, command };
158
167
  }
@@ -372,7 +381,8 @@ async function main() {
372
381
  // Client-side: install
373
382
  if (action === "install") {
374
383
  process.stderr.write("[camoufox-cli] Downloading browser...\n");
375
- execFileSync("npx", ["camoufox-js", "fetch"], { stdio: "inherit" });
384
+ const { installBrowser } = await import("./install.js");
385
+ await installBrowser();
376
386
  process.stderr.write("[camoufox-cli] Browser installed.\n");
377
387
  if ((command.params as any)?.with_deps) {
378
388
  installSystemDeps();
@@ -480,7 +490,12 @@ Flags:
480
490
  --persistent [path] Persistent identity — freeze fingerprint/OS/locale + store cookies/state (default: ~/.camoufox-cli/profiles/<session>)
481
491
  --proxy <url> Proxy server (e.g. http://host:port or https://host:443)
482
492
  --no-geoip Disable automatic GeoIP spoofing (auto-enabled with --proxy)
483
- --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")`;
493
+ --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")
494
+
495
+ Config file:
496
+ ~/.camoufox-cli/config.json sets defaults for the flags above (override the
497
+ path with $CAMOUFOX_CLI_CONFIG). Command-line flags always take precedence.
498
+ Use a "default" block plus optional per-session blocks under "sessions".`;
484
499
 
485
500
  const isDirectRun = (() => {
486
501
  try {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Optional config file support.
3
+ *
4
+ * A JSON file at ~/.camoufox-cli/config.json (override with the
5
+ * CAMOUFOX_CLI_CONFIG env var) supplies default values for daemon-launch
6
+ * flags, so common settings (proxy, locale, persistent…) don't have to be
7
+ * repeated on every invocation.
8
+ *
9
+ * Precedence: command-line flag > config `sessions.<name>` block >
10
+ * config `default` block > built-in default.
11
+ *
12
+ * Only flags that affect daemon launch are honored. `session` itself is never
13
+ * read from config — it selects which block to apply, so it can only come from
14
+ * the command line. Per-command flags (--full, -i, …) are never read.
15
+ *
16
+ * The config only takes effect when a session's daemon first launches; an
17
+ * already-running daemon is reused as-is (see ensureDaemon).
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+
24
+ export interface ConfigDefaults {
25
+ headed?: boolean;
26
+ timeout?: number;
27
+ json?: boolean;
28
+ persistent?: string | null;
29
+ proxy?: string | null;
30
+ geoip?: boolean;
31
+ locale?: string | null;
32
+ }
33
+
34
+ // Flags a config file may set, grouped by the value type each one expects.
35
+ // "session" is intentionally absent — it selects which block to apply, so it
36
+ // must come from the command line.
37
+ const BOOL_KEYS = new Set(["headed", "geoip", "json"]);
38
+ const STR_KEYS = new Set(["proxy", "locale"]);
39
+ const ALLOWED_KEYS = new Set([...BOOL_KEYS, ...STR_KEYS, "timeout", "persistent"]);
40
+ const INVALID = Symbol("invalid");
41
+
42
+ /** Resolved config path, read at call time so the env var can be overridden (incl. by tests). */
43
+ export function configPath(): string {
44
+ return process.env.CAMOUFOX_CLI_CONFIG || path.join(os.homedir(), ".camoufox-cli", "config.json");
45
+ }
46
+
47
+ /**
48
+ * Return config-derived flag defaults for `session`.
49
+ *
50
+ * Merges the top-level `default` block with the `sessions.<session>` block
51
+ * (the latter wins). Returns `{}` when the file is absent or malformed — a
52
+ * broken config never blocks a command, it is only ignored with a warning on
53
+ * stderr.
54
+ */
55
+ export function loadDefaults(session: string): ConfigDefaults {
56
+ const p = configPath();
57
+ if (!fs.existsSync(p)) return {};
58
+
59
+ let raw: unknown;
60
+ try {
61
+ raw = JSON.parse(fs.readFileSync(p, "utf8"));
62
+ } catch (e: any) {
63
+ process.stderr.write(`[camoufox-cli] Ignoring config ${p}: ${e.message}\n`);
64
+ return {};
65
+ }
66
+
67
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
68
+ process.stderr.write(`[camoufox-cli] Ignoring config ${p}: top level must be a JSON object\n`);
69
+ return {};
70
+ }
71
+
72
+ const obj = raw as Record<string, unknown>;
73
+ const merged: Record<string, unknown> = {};
74
+ Object.assign(merged, clean(obj.default, p));
75
+
76
+ const sessions = obj.sessions;
77
+ if (typeof sessions === "object" && sessions !== null && !Array.isArray(sessions)) {
78
+ Object.assign(merged, clean((sessions as Record<string, unknown>)[session], p));
79
+ } else if (sessions !== undefined) {
80
+ process.stderr.write(`[camoufox-cli] Ignoring config ${p}: "sessions" must be an object\n`);
81
+ }
82
+
83
+ return normalize(merged);
84
+ }
85
+
86
+ /** Keep only allowed keys from a block, warning on anything else. */
87
+ function clean(block: unknown, p: string): Record<string, unknown> {
88
+ if (block === undefined || block === null) return {};
89
+ if (typeof block !== "object" || Array.isArray(block)) {
90
+ process.stderr.write(`[camoufox-cli] Ignoring config ${p}: blocks must be objects\n`);
91
+ return {};
92
+ }
93
+ const out: Record<string, unknown> = {};
94
+ for (const [key, value] of Object.entries(block as Record<string, unknown>)) {
95
+ if (ALLOWED_KEYS.has(key)) out[key] = value;
96
+ else if (key === "session") process.stderr.write(`[camoufox-cli] Ignoring "session" in config — set it with --session on the command line\n`);
97
+ else process.stderr.write(`[camoufox-cli] Ignoring unknown config key: ${key}\n`);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Validate/coerce each config value to the type its flag expects.
104
+ *
105
+ * A value of the wrong type is dropped with a warning rather than passed
106
+ * through, so a malformed config is always ignored, never fatal: a non-string
107
+ * proxy/locale/persistent would crash daemon launch, a non-numeric timeout
108
+ * would fail silently, and a non-bool toggle (e.g. the string "false") would
109
+ * be silently truthy.
110
+ */
111
+ function normalize(flags: Record<string, unknown>): ConfigDefaults {
112
+ const out: Record<string, unknown> = {};
113
+ for (const [key, value] of Object.entries(flags)) {
114
+ const coerced = coerce(key, value);
115
+ if (coerced === INVALID) {
116
+ process.stderr.write(`[camoufox-cli] Ignoring invalid ${key} in config: ${JSON.stringify(value)}\n`);
117
+ } else {
118
+ out[key] = coerced;
119
+ }
120
+ }
121
+ return out as ConfigDefaults;
122
+ }
123
+
124
+ /** Return the normalized value if it fits the flag's type, else the INVALID sentinel. */
125
+ function coerce(key: string, value: unknown): unknown {
126
+ if (BOOL_KEYS.has(key)) return typeof value === "boolean" ? value : INVALID;
127
+ // null is accepted as "unset" (falls back to the built-in default).
128
+ if (STR_KEYS.has(key)) return value === null || typeof value === "string" ? value : INVALID;
129
+ if (key === "timeout") {
130
+ // a finite number only — excludes bool, string and null
131
+ return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : INVALID;
132
+ }
133
+ if (key === "persistent") {
134
+ // true -> "" (default profile path, resolved in main); false -> null
135
+ // (disabled); null/string kept as-is; anything else invalid.
136
+ if (typeof value === "boolean") return value ? "" : null;
137
+ return value === null || typeof value === "string" ? value : INVALID;
138
+ }
139
+ return value; // unreachable: clean already filtered to known keys
140
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Browser installation with resilient GitHub release discovery.
3
+ *
4
+ * Anonymous GitHub API requests are limited to 60/hour per IP, which makes
5
+ * installs flaky on servers behind shared egress IPs (CI runners, cloud
6
+ * hosts). Mitigations:
7
+ *
8
+ * - If the API fails, release assets are discovered by paging through
9
+ * github.com release pages, which are not behind the API rate limit.
10
+ * - The GeoIP database is downloaded from a fixed releases/latest/download
11
+ * URL (no API involved) at install time and before geoip launches, so
12
+ * upstream's lazy API-based download never triggers.
13
+ */
14
+
15
+ import { existsSync, rmSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import { DefaultAddons, maybeDownloadAddons } from "camoufox-js/dist/addons.js";
19
+ import { downloadMMDB } from "camoufox-js/dist/locale.js";
20
+ import { CamoufoxFetcher, INSTALL_DIR, installedVerStr, webdl } from "camoufox-js/dist/pkgman.js";
21
+
22
+ const HEADERS = { "User-Agent": "camoufox-cli" };
23
+ const MMDB_URL = "https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-City.mmdb";
24
+
25
+ async function assetsViaApi(repo: string): Promise<any[]> {
26
+ const resp = await fetch(`https://api.github.com/repos/${repo}/releases`, { headers: HEADERS });
27
+ if (!resp.ok) throw new Error(`GitHub API responded with ${resp.status}`);
28
+ const releases = (await resp.json()) as any[];
29
+ return releases.flatMap((release) => release.assets ?? []);
30
+ }
31
+
32
+ /**
33
+ * Discover release assets by paging through github.com release pages,
34
+ * newest release first. Lazy: stops requesting once the caller stops.
35
+ */
36
+ export async function* assetsViaWeb(repo: string): AsyncGenerator<any> {
37
+ const escaped = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ const tagRe = new RegExp(`/${escaped}/releases/tag/([^"<]+)`, "g");
39
+ const linkRe = new RegExp(`href="(/${escaped}/releases/download/[^"]+)"`, "g");
40
+
41
+ const seen = new Set<string>();
42
+ for (let pageNum = 1; ; pageNum++) {
43
+ const listing = await fetch(`https://github.com/${repo}/releases?page=${pageNum}`, { headers: HEADERS });
44
+ if (!listing.ok) throw new Error(`github.com responded with ${listing.status}`);
45
+ const tags = [...new Set([...(await listing.text()).matchAll(tagRe)].map((m) => m[1]))]
46
+ .filter((tag) => !seen.has(tag));
47
+ if (tags.length === 0) return;
48
+ for (const tag of tags) seen.add(tag);
49
+ for (const tag of tags) {
50
+ const page = await fetch(`https://github.com/${repo}/releases/expanded_assets/${tag}`, { headers: HEADERS });
51
+ if (!page.ok) continue;
52
+ for (const m of (await page.text()).matchAll(linkRe)) {
53
+ yield {
54
+ name: decodeURIComponent(m[1].split("/").pop()!),
55
+ browser_download_url: `https://github.com${m[1]}`,
56
+ };
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Yield all release assets of a repo, newest release first. */
63
+ export async function* iterReleaseAssets(repo: string): AsyncGenerator<any> {
64
+ let assets: any[];
65
+ try {
66
+ assets = await assetsViaApi(repo);
67
+ } catch (e) {
68
+ process.stderr.write(`[camoufox-cli] GitHub API failed (${e}), falling back to github.com pages...\n`);
69
+ yield* assetsViaWeb(repo);
70
+ return;
71
+ }
72
+ yield* assets;
73
+ }
74
+
75
+ class ResilientFetcher extends CamoufoxFetcher {
76
+ async getAsset(): Promise<any> {
77
+ for await (const asset of iterReleaseAssets(this.githubRepo)) {
78
+ const data = this.checkAsset(asset);
79
+ if (data) return data;
80
+ }
81
+ this.missingAssetError();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Download the GeoIP database if missing, without hitting the GitHub API.
87
+ * Failures are non-fatal: upstream still lazily downloads on first use.
88
+ */
89
+ export async function ensureMmdb(): Promise<void> {
90
+ const mmdbFile = join(INSTALL_DIR.toString(), "GeoLite2-City.mmdb");
91
+ if (existsSync(mmdbFile)) return;
92
+ try {
93
+ const buf = await webdl(MMDB_URL, "Downloading GeoIP database", true);
94
+ writeFileSync(mmdbFile, buf);
95
+ return;
96
+ } catch {
97
+ rmSync(mmdbFile, { force: true });
98
+ }
99
+ try {
100
+ await downloadMMDB(); // upstream API-based path, as a last resort
101
+ } catch (e) {
102
+ rmSync(mmdbFile, { force: true });
103
+ process.stderr.write(`[camoufox-cli] GeoIP database download failed (${e}).\n`);
104
+ }
105
+ }
106
+
107
+ /** Download and install the Camoufox browser, GeoIP database and addons. */
108
+ export async function installBrowser(): Promise<void> {
109
+ const fetcher = new ResilientFetcher();
110
+ await fetcher.init();
111
+
112
+ let installed: string | null = null;
113
+ try {
114
+ installed = installedVerStr();
115
+ } catch {
116
+ installed = null;
117
+ }
118
+ if (installed === fetcher.verstr) {
119
+ process.stderr.write(`[camoufox-cli] Camoufox v${installed} is already up to date.\n`);
120
+ } else {
121
+ await fetcher.install();
122
+ }
123
+
124
+ await ensureMmdb();
125
+ maybeDownloadAddons(DefaultAddons);
126
+ }
@@ -1,5 +1,9 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
2
5
  import { buildCommand, getSocketPath, parseArgs } from "../src/cli.js";
6
+ import { loadDefaults } from "../src/config.js";
3
7
 
4
8
  // buildCommand calls process.exit on error; mock it to throw instead
5
9
  beforeEach(() => {
@@ -7,6 +11,8 @@ beforeEach(() => {
7
11
  throw new Error(`process.exit(${code})`);
8
12
  });
9
13
  vi.spyOn(process.stderr, "write").mockImplementation(() => true);
14
+ // Isolate from any real ~/.camoufox-cli/config.json on the dev's machine.
15
+ process.env.CAMOUFOX_CLI_CONFIG = path.join(os.tmpdir(), "camoufox-cli-absent-dir", "config.json");
10
16
  });
11
17
 
12
18
  describe("buildCommand", () => {
@@ -298,3 +304,152 @@ describe("getSocketPath", () => {
298
304
  expect(getSocketPath("my-session")).toBe("/tmp/camoufox-cli-my-session.sock");
299
305
  });
300
306
  });
307
+
308
+ describe("config file", () => {
309
+ let dir: string;
310
+ let cfg: string;
311
+
312
+ beforeEach(() => {
313
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-cli-test-"));
314
+ cfg = path.join(dir, "config.json");
315
+ process.env.CAMOUFOX_CLI_CONFIG = cfg;
316
+ });
317
+
318
+ afterEach(() => {
319
+ delete process.env.CAMOUFOX_CLI_CONFIG;
320
+ fs.rmSync(dir, { recursive: true, force: true });
321
+ });
322
+
323
+ const write = (obj: unknown) => fs.writeFileSync(cfg, JSON.stringify(obj));
324
+
325
+ // --- loadDefaults ---
326
+ it("absent file -> {}", () => {
327
+ expect(loadDefaults("default")).toEqual({});
328
+ });
329
+
330
+ it("default block", () => {
331
+ write({ default: { timeout: 60, headed: true } });
332
+ const out = loadDefaults("default");
333
+ expect(out.timeout).toBe(60);
334
+ expect(out.headed).toBe(true);
335
+ });
336
+
337
+ it("session overrides default", () => {
338
+ write({ default: { locale: "en-US", timeout: 60 }, sessions: { work: { locale: "zh-CN" } } });
339
+ const out = loadDefaults("work");
340
+ expect(out.locale).toBe("zh-CN"); // session block wins
341
+ expect(out.timeout).toBe(60); // inherited from default block
342
+ });
343
+
344
+ it("unknown session uses default block", () => {
345
+ write({ default: { locale: "en-US" }, sessions: { work: { locale: "zh-CN" } } });
346
+ expect(loadDefaults("other").locale).toBe("en-US");
347
+ });
348
+
349
+ it("session key excluded", () => {
350
+ write({ default: { session: "ignored", proxy: "http://h:1" } });
351
+ const out = loadDefaults("default") as any;
352
+ expect(out.session).toBeUndefined();
353
+ expect(out.proxy).toBe("http://h:1");
354
+ });
355
+
356
+ it("unknown key ignored", () => {
357
+ write({ default: { bogus: 1, headed: true } });
358
+ const out = loadDefaults("default") as any;
359
+ expect(out.bogus).toBeUndefined();
360
+ expect(out.headed).toBe(true);
361
+ });
362
+
363
+ it("persistent true -> empty-string sentinel", () => {
364
+ write({ default: { persistent: true } });
365
+ expect(loadDefaults("default").persistent).toBe("");
366
+ });
367
+
368
+ it("persistent false -> null", () => {
369
+ write({ default: { persistent: false } });
370
+ expect(loadDefaults("default").persistent).toBeNull();
371
+ });
372
+
373
+ it("persistent path kept", () => {
374
+ write({ default: { persistent: "/tmp/p" } });
375
+ expect(loadDefaults("default").persistent).toBe("/tmp/p");
376
+ });
377
+
378
+ it("invalid persistent dropped", () => {
379
+ // A number would crash daemon launch; it must be dropped, not passed through.
380
+ write({ default: { persistent: 123, headed: true } });
381
+ const out = loadDefaults("default") as any;
382
+ expect(out.persistent).toBeUndefined();
383
+ expect(out.headed).toBe(true);
384
+ });
385
+
386
+ it("persistent null disables", () => {
387
+ write({ default: { persistent: null } });
388
+ expect(loadDefaults("default").persistent).toBeNull();
389
+ });
390
+
391
+ it("invalid timeout dropped", () => {
392
+ write({ default: { timeout: "abc", headed: true } });
393
+ const out = loadDefaults("default") as any;
394
+ expect(out.timeout).toBeUndefined();
395
+ expect(out.headed).toBe(true);
396
+ });
397
+
398
+ it("timeout bool dropped", () => {
399
+ write({ default: { timeout: true } });
400
+ expect("timeout" in loadDefaults("default")).toBe(false);
401
+ });
402
+
403
+ it("timeout float truncated", () => {
404
+ write({ default: { timeout: 3600.9 } });
405
+ expect(loadDefaults("default").timeout).toBe(3600);
406
+ });
407
+
408
+ it("invalid proxy dropped", () => {
409
+ // A non-string proxy would crash daemon launch; it must be dropped.
410
+ write({ default: { proxy: 123, locale: "en-US" } });
411
+ const out = loadDefaults("default") as any;
412
+ expect(out.proxy).toBeUndefined();
413
+ expect(out.locale).toBe("en-US");
414
+ });
415
+
416
+ it("bool field wrong type dropped", () => {
417
+ write({ default: { headed: "false", geoip: "no" } });
418
+ const out = loadDefaults("default") as any;
419
+ expect(out.headed).toBeUndefined();
420
+ expect(out.geoip).toBeUndefined();
421
+ });
422
+
423
+ it("malformed json ignored", () => {
424
+ fs.writeFileSync(cfg, "{not valid json");
425
+ expect(loadDefaults("default")).toEqual({});
426
+ });
427
+
428
+ it("non-object top level ignored", () => {
429
+ write([1, 2, 3]);
430
+ expect(loadDefaults("default")).toEqual({});
431
+ });
432
+
433
+ // --- parseArgs integration (precedence: CLI > config > built-in) ---
434
+ it("config supplies defaults", () => {
435
+ write({ default: { proxy: "http://h:1", headed: true } });
436
+ const { flags } = parseArgs(["open", "https://example.com"]);
437
+ expect(flags.proxy).toBe("http://h:1");
438
+ expect(flags.headed).toBe(true);
439
+ });
440
+
441
+ it("CLI overrides config", () => {
442
+ write({ default: { timeout: 60 } });
443
+ const { flags } = parseArgs(["--timeout", "999", "open", "x"]);
444
+ expect(flags.timeout).toBe(999);
445
+ });
446
+
447
+ it("session block applies", () => {
448
+ write({ sessions: { work: { locale: "zh-CN" } } });
449
+ const { flags } = parseArgs(["--session", "work", "open", "x"]);
450
+ expect(flags.locale).toBe("zh-CN");
451
+ // A different session doesn't pick up the "work" block.
452
+ const { flags: f2 } = parseArgs(["open", "x"]);
453
+ expect(f2.locale).toBeNull();
454
+ });
455
+ });