stealth-cli 0.5.1 → 0.6.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.
@@ -5,6 +5,8 @@
5
5
  import ora from 'ora';
6
6
  import { launchBrowser, closeBrowser, navigate, takeScreenshot, getUrl, waitForReady } from '../browser.js';
7
7
  import { log } from '../output.js';
8
+ import { resolveOpts } from '../utils/resolve-opts.js';
9
+ import { handleError } from '../errors.js';
8
10
 
9
11
  export function registerScreenshot(program) {
10
12
  program
@@ -21,11 +23,12 @@ export function registerScreenshot(program) {
21
23
  .option('--no-headless', 'Show browser window')
22
24
  .option('--quality <n>', 'JPEG quality (1-100), only for .jpg output')
23
25
  .option('--humanize', 'Enable human behavior simulation')
24
- .option('--retries <n>', 'Max retries on failure', '2')
26
+ .option('--retries <n>', 'Max retries on failure')
25
27
  .option('--profile <name>', 'Use a browser profile')
26
28
  .option('--session <name>', 'Use/restore a named session')
27
29
  .option('--proxy-rotate', 'Rotate proxy from pool')
28
30
  .action(async (url, opts) => {
31
+ opts = resolveOpts(opts);
29
32
  const spinner = ora('Launching stealth browser...').start();
30
33
  let handle;
31
34
 
@@ -37,8 +40,8 @@ export function registerScreenshot(program) {
37
40
  profile: opts.profile,
38
41
  session: opts.session,
39
42
  viewport: {
40
- width: parseInt(opts.width),
41
- height: parseInt(opts.height),
43
+ width: opts.width,
44
+ height: opts.height,
42
45
  },
43
46
  });
44
47
 
@@ -51,11 +54,11 @@ export function registerScreenshot(program) {
51
54
  spinner.text = `Navigating to ${url}...`;
52
55
  await navigate(handle, url, {
53
56
  humanize: opts.humanize,
54
- retries: parseInt(opts.retries),
57
+ retries: opts.retries,
55
58
  });
56
59
 
57
60
  if (!handle.isDaemon) {
58
- await waitForReady(handle.page, { timeout: parseInt(opts.wait) });
61
+ await waitForReady(handle.page, { timeout: opts.wait });
59
62
  }
60
63
 
61
64
  spinner.text = 'Taking screenshot...';
@@ -72,7 +75,7 @@ export function registerScreenshot(program) {
72
75
 
73
76
  if (opts.output.endsWith('.jpg') || opts.output.endsWith('.jpeg')) {
74
77
  screenshotOpts.type = 'jpeg';
75
- if (opts.quality) screenshotOpts.quality = parseInt(opts.quality);
78
+ if (opts.quality) screenshotOpts.quality = opts.quality;
76
79
  } else {
77
80
  screenshotOpts.type = 'png';
78
81
  }
@@ -87,8 +90,7 @@ export function registerScreenshot(program) {
87
90
  log.dim(` Size: ${opts.width}x${opts.height}${opts.full ? ' (full page)' : ''}`);
88
91
  } catch (err) {
89
92
  spinner.stop();
90
- log.error(`Screenshot failed: ${err.message}`);
91
- process.exit(1);
93
+ handleError(err, { log });
92
94
  } finally {
93
95
  if (handle) await closeBrowser(handle);
94
96
  }
@@ -12,6 +12,8 @@ import { getExtractorByEngine } from '../extractors/index.js';
12
12
  import * as googleExtractor from '../extractors/google.js';
13
13
  import { humanScroll, randomDelay, warmup } from '../humanize.js';
14
14
  import { formatOutput, log } from '../output.js';
15
+ import { resolveOpts } from '../utils/resolve-opts.js';
16
+ import { handleError, BlockedError } from '../errors.js';
15
17
 
16
18
  export function registerSearch(program) {
17
19
  program
@@ -19,18 +21,19 @@ export function registerSearch(program) {
19
21
  .description('Search the web with anti-detection')
20
22
  .argument('<engine>', `Search engine: ${getSupportedEngines().join(', ')}`)
21
23
  .argument('<query>', 'Search query')
22
- .option('-f, --format <format>', 'Output format: text, json, snapshot', 'text')
24
+ .option('-f, --format <format>', 'Output format: text, json, snapshot')
23
25
  .option('-n, --num <n>', 'Max results to extract', '10')
24
26
  .option('--proxy <proxy>', 'Proxy server')
25
27
  .option('--no-headless', 'Show browser window')
26
28
  .option('--humanize', 'Simulate human behavior (auto for Google)')
27
29
  .option('--warmup', 'Visit a random site before searching (helps bypass detection)')
28
- .option('--retries <n>', 'Max retries on failure', '2')
30
+ .option('--retries <n>', 'Max retries on failure')
29
31
  .option('--also-ask', 'Include "People also ask" questions (Google only)')
30
32
  .option('--profile <name>', 'Use a browser profile')
31
33
  .option('--session <name>', 'Use/restore a named session')
32
34
  .option('--proxy-rotate', 'Rotate proxy from pool')
33
35
  .action(async (engine, query, opts) => {
36
+ opts = resolveOpts(opts);
34
37
  const url = expandMacro(engine, query);
35
38
 
36
39
  if (!url) {
@@ -69,7 +72,7 @@ export function registerSearch(program) {
69
72
  if (!success) {
70
73
  // Fallback: direct URL navigation
71
74
  spinner.text = 'Fallback: direct navigation...';
72
- await navigate(handle, url, { retries: parseInt(opts.retries) });
75
+ await navigate(handle, url, { retries: opts.retries });
73
76
  }
74
77
 
75
78
  await waitForReady(handle.page, { timeout: 5000 });
@@ -80,18 +83,7 @@ export function registerSearch(program) {
80
83
  const currentUrl = handle.page.url();
81
84
  if (googleExtractor.isBlocked(currentUrl)) {
82
85
  spinner.stop();
83
- log.warn('Google detected automation and blocked the request');
84
- log.dim(' Try: --proxy <proxy> or --warmup flag');
85
- log.dim(' Or use a different engine: stealth search duckduckgo "..."');
86
-
87
- if (opts.format === 'json') {
88
- console.log(formatOutput({
89
- engine, query, url: currentUrl,
90
- blocked: true, results: [], count: 0,
91
- timestamp: new Date().toISOString(),
92
- }, 'json'));
93
- }
94
- return;
86
+ throw new BlockedError('Google', currentUrl);
95
87
  }
96
88
  }
97
89
  // --- Other engines: direct navigation ---
@@ -99,7 +91,7 @@ export function registerSearch(program) {
99
91
  spinner.text = `Navigating to ${engine}...`;
100
92
  await navigate(handle, url, {
101
93
  humanize: opts.humanize,
102
- retries: parseInt(opts.retries),
94
+ retries: opts.retries,
103
95
  });
104
96
 
105
97
  if (!handle.isDaemon) {
@@ -120,7 +112,7 @@ export function registerSearch(program) {
120
112
  let alsoAsk = [];
121
113
 
122
114
  if (!handle.isDaemon) {
123
- results = await extractor.extractResults(handle.page, parseInt(opts.num));
115
+ results = await extractor.extractResults(handle.page, opts.num);
124
116
 
125
117
  // Google "People also ask"
126
118
  if (isGoogle && opts.alsoAsk) {
@@ -153,8 +145,7 @@ export function registerSearch(program) {
153
145
  log.success(`Search complete: ${currentUrl}`);
154
146
  } catch (err) {
155
147
  spinner.stop();
156
- log.error(`Search failed: ${err.message}`);
157
- process.exit(1);
148
+ handleError(err, { log });
158
149
  } finally {
159
150
  if (handle) await closeBrowser(handle);
160
151
  }
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import http from 'http';
9
- import { launchOptions } from 'camoufox-js';
10
- import { firefox } from 'playwright-core';
11
- import os from 'os';
9
+ import crypto from 'crypto';
12
10
  import { log } from '../output.js';
11
+ import { createBrowser, createContext, extractPageText } from '../utils/browser-factory.js';
13
12
 
14
13
  export function registerServe(program) {
15
14
  program
@@ -19,32 +18,42 @@ export function registerServe(program) {
19
18
  .option('--host <host>', 'Host to bind to', '127.0.0.1')
20
19
  .option('--proxy <proxy>', 'Default proxy for all requests')
21
20
  .option('--no-headless', 'Show browser window')
21
+ .option('--token <token>', 'API token for authentication (auto-generated if not set)')
22
+ .option('--no-auth', 'Disable authentication (only recommended on localhost)')
22
23
  .action(async (opts) => {
23
- const port = parseInt(opts.port);
24
+ const port = parseInt(opts.port, 10);
24
25
  const host = opts.host;
26
+ const apiToken = opts.token || crypto.randomBytes(24).toString('hex');
25
27
 
26
28
  log.info('Starting stealth API server...');
27
29
 
28
30
  // Launch browser
29
- const hostOS = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
30
- const options = await launchOptions({
31
- headless: opts.headless,
32
- os: hostOS,
33
- humanize: true,
34
- enable_cache: true,
35
- });
36
- const browser = await firefox.launch(options);
31
+ const browser = await createBrowser({ headless: opts.headless });
37
32
 
38
33
  // Page pool
39
34
  const pages = new Map(); // id → { page, context, lastUsed }
35
+ const MAX_TABS = 20;
40
36
  let idCounter = 0;
41
37
 
42
38
  async function createPage() {
43
- const context = await browser.newContext({
44
- viewport: { width: 1280, height: 720 },
45
- locale: 'en-US',
46
- timezoneId: 'America/Los_Angeles',
47
- });
39
+ // Evict oldest tab if limit reached
40
+ if (pages.size >= MAX_TABS) {
41
+ let oldestId = null;
42
+ let oldestTime = Infinity;
43
+ for (const [id, entry] of pages) {
44
+ if (entry.lastUsed < oldestTime) {
45
+ oldestTime = entry.lastUsed;
46
+ oldestId = id;
47
+ }
48
+ }
49
+ if (oldestId) {
50
+ const old = pages.get(oldestId);
51
+ await old.context.close().catch(() => {});
52
+ pages.delete(oldestId);
53
+ }
54
+ }
55
+
56
+ const context = await createContext(browser);
48
57
  const page = await context.newPage();
49
58
  const id = `tab-${++idCounter}`;
50
59
  pages.set(id, { page, context, lastUsed: Date.now() });
@@ -83,13 +92,21 @@ export function registerServe(program) {
83
92
 
84
93
  try {
85
94
  const body = method === 'POST' ? await parseBody(req) : {};
86
- const query = Object.fromEntries(url.searchParams);
87
95
 
88
- // --- Health ---
96
+ // --- Health (no auth required) ---
89
97
  if (route === '/health') {
90
98
  return json(res, { ok: true, engine: 'camoufox', pages: pages.size });
91
99
  }
92
100
 
101
+ // --- Auth check ---
102
+ if (opts.auth !== false) {
103
+ const authHeader = req.headers['authorization'];
104
+ const token = authHeader ? authHeader.replace(/^Bearer\s+/i, '') : '';
105
+ if (token !== apiToken) {
106
+ return json(res, { error: 'Unauthorized. Use: -H "Authorization: Bearer <token>"' }, 401);
107
+ }
108
+ }
109
+
93
110
  // --- Create tab ---
94
111
  if (route === '/tabs' && method === 'POST') {
95
112
  const { url: targetUrl } = body;
@@ -130,11 +147,7 @@ export function registerServe(program) {
130
147
  }
131
148
 
132
149
  case 'text': {
133
- const text = await page.evaluate(() => {
134
- const c = document.body.cloneNode(true);
135
- c.querySelectorAll('script,style,noscript').forEach((e) => e.remove());
136
- return c.innerText || '';
137
- });
150
+ const text = await page.evaluate(extractPageText);
138
151
  return json(res, { ok: true, text, url: page.url() });
139
152
  }
140
153
 
@@ -213,6 +226,12 @@ export function registerServe(program) {
213
226
 
214
227
  server.listen(port, host, () => {
215
228
  log.success(`Stealth API server running on http://${host}:${port}`);
229
+ if (opts.auth !== false) {
230
+ log.info(`API Token: ${apiToken}`);
231
+ log.dim(` Use: curl -H "Authorization: Bearer ${apiToken}" ...`);
232
+ } else {
233
+ log.warn('Authentication disabled (--no-auth)');
234
+ }
216
235
  log.dim(' Endpoints:');
217
236
  log.dim(' GET /health — Server status');
218
237
  log.dim(' POST /tabs — Create tab { url }');
package/src/daemon.js CHANGED
@@ -14,8 +14,7 @@ import http from 'http';
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import os from 'os';
17
- import { launchOptions } from 'camoufox-js';
18
- import { firefox } from 'playwright-core';
17
+ import { createBrowser, createContext, extractPageText } from './utils/browser-factory.js';
19
18
 
20
19
  const STEALTH_DIR = path.join(os.homedir(), '.stealth');
21
20
  const SOCKET_PATH = path.join(STEALTH_DIR, 'daemon.sock');
@@ -50,16 +49,6 @@ function cleanup() {
50
49
  try { fs.unlinkSync(PID_PATH); } catch {}
51
50
  }
52
51
 
53
- /**
54
- * Get host OS for fingerprint
55
- */
56
- function getHostOS() {
57
- const platform = os.platform();
58
- if (platform === 'darwin') return 'macos';
59
- if (platform === 'win32') return 'windows';
60
- return 'linux';
61
- }
62
-
63
52
  /**
64
53
  * Start the daemon server
65
54
  */
@@ -81,13 +70,7 @@ export async function startDaemon(opts = {}) {
81
70
 
82
71
  // Launch browser
83
72
  log('Launching Camoufox browser...');
84
- const options = await launchOptions({
85
- headless: true,
86
- os: getHostOS(),
87
- humanize: true,
88
- enable_cache: true,
89
- });
90
- const browser = await firefox.launch(options);
73
+ const browser = await createBrowser({ headless: true });
91
74
  log('Browser launched');
92
75
 
93
76
  // Track contexts for reuse
@@ -121,19 +104,7 @@ export async function startDaemon(opts = {}) {
121
104
  }
122
105
  }
123
106
 
124
- const {
125
- locale = 'en-US',
126
- timezone = 'America/Los_Angeles',
127
- viewport = { width: 1280, height: 720 },
128
- } = contextOpts;
129
-
130
- const context = await browser.newContext({
131
- viewport,
132
- locale,
133
- timezoneId: timezone,
134
- permissions: ['geolocation'],
135
- geolocation: { latitude: 37.7749, longitude: -122.4194 },
136
- });
107
+ const context = await createContext(browser, contextOpts);
137
108
 
138
109
  const page = await context.newPage();
139
110
  const entry = { context, page, lastUsed: Date.now() };
@@ -202,11 +173,7 @@ export async function startDaemon(opts = {}) {
202
173
  if (route === '/text') {
203
174
  const { key = 'default' } = body;
204
175
  const ctx = await getOrCreateContext(key);
205
- const text = await ctx.page.evaluate(() => {
206
- const clone = document.body.cloneNode(true);
207
- clone.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
208
- return clone.innerText || '';
209
- });
176
+ const text = await ctx.page.evaluate(extractPageText);
210
177
  res.end(JSON.stringify({ ok: true, text, url: ctx.page.url() }));
211
178
  return;
212
179
  }
package/src/errors.js CHANGED
@@ -42,7 +42,7 @@ export class NavigationError extends StealthError {
42
42
  constructor(url, cause) {
43
43
  const msg = `Failed to navigate to ${url}`;
44
44
  let hint = 'Check the URL and your network connection';
45
- if (cause?.message?.includes('timeout')) {
45
+ if (cause?.message?.toLowerCase().includes('timeout')) {
46
46
  hint = 'Page load timed out. Try --wait <ms> or --retries <n>';
47
47
  } else if (cause?.message?.includes('net::ERR_')) {
48
48
  hint = 'Network error. Check DNS, proxy, or firewall';
@@ -103,20 +103,31 @@ export class BlockedError extends StealthError {
103
103
 
104
104
  /**
105
105
  * Format and print error with hint, then exit
106
+ *
107
+ * @param {Error} err - The error to handle
108
+ * @param {object} [opts]
109
+ * @param {object} [opts.log] - Logger (default: console with stderr)
110
+ * @param {boolean} [opts.exit=true] - Whether to call process.exit
106
111
  */
107
- export function handleError(err) {
108
- const { log } = loadOutput();
112
+ export function handleError(err, opts = {}) {
113
+ const { exit = true } = opts;
114
+
115
+ // Use provided log or fallback to stderr console
116
+ const log = opts.log || {
117
+ error: (msg) => console.error(`\x1b[31m✖\x1b[0m ${msg}`),
118
+ dim: (msg) => console.error(`\x1b[2m${msg}\x1b[0m`),
119
+ };
109
120
 
110
121
  if (err instanceof StealthError) {
111
122
  log.error(err.message);
112
123
  if (err.hint) log.dim(` Hint: ${err.hint}`);
113
- process.exit(err.code);
124
+ if (exit) process.exit(err.code);
125
+ return err.code;
114
126
  }
115
127
 
116
- // Unknown error
128
+ // Unknown error — detect common patterns and add helpful hints
117
129
  log.error(err.message || String(err));
118
130
 
119
- // Common error patterns → helpful hints
120
131
  const msg = err.message || '';
121
132
  if (msg.includes('ECONNREFUSED')) {
122
133
  log.dim(' Hint: Connection refused. Is the target server running?');
@@ -124,13 +135,10 @@ export function handleError(err) {
124
135
  log.dim(' Hint: DNS lookup failed. Check the URL');
125
136
  } else if (msg.includes('camoufox')) {
126
137
  log.dim(' Hint: Try: npx camoufox-js fetch');
138
+ } else if (msg.includes('timeout') || msg.includes('Timeout')) {
139
+ log.dim(' Hint: Try increasing --retries or --wait');
127
140
  }
128
141
 
129
- process.exit(1);
130
- }
131
-
132
- // Lazy import to avoid circular dependency
133
- function loadOutput() {
134
- // Use dynamic require-like pattern
135
- return { log: console };
142
+ if (exit) process.exit(1);
143
+ return 1;
136
144
  }
package/src/mcp-server.js CHANGED
@@ -18,9 +18,11 @@
18
18
  * }
19
19
  */
20
20
 
21
- import { launchOptions } from 'camoufox-js';
22
- import { firefox } from 'playwright-core';
23
- import os from 'os';
21
+ import { createRequire } from 'module';
22
+ import { createBrowser, createContext, extractPageText } from './utils/browser-factory.js';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { version: PKG_VERSION } = require('../package.json');
24
26
 
25
27
  // --- MCP Protocol Implementation (stdio JSON-RPC) ---
26
28
 
@@ -123,9 +125,7 @@ class McpServer {
123
125
 
124
126
  async ensureBrowser() {
125
127
  if (this.browser && this.browser.isConnected()) return this.browser;
126
- const hostOS = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
127
- const options = await launchOptions({ headless: true, os: hostOS, humanize: true, enable_cache: true });
128
- this.browser = await firefox.launch(options);
128
+ this.browser = await createBrowser();
129
129
  return this.browser;
130
130
  }
131
131
 
@@ -140,11 +140,7 @@ class McpServer {
140
140
  }
141
141
  }
142
142
  await this.ensureBrowser();
143
- const context = await this.browser.newContext({
144
- viewport: { width: 1280, height: 720 },
145
- locale: 'en-US',
146
- timezoneId: 'America/Los_Angeles',
147
- });
143
+ const context = await createContext(this.browser);
148
144
  const page = await context.newPage();
149
145
  const entry = { context, page };
150
146
  this.contexts.set(key, entry);
@@ -164,11 +160,7 @@ class McpServer {
164
160
  return text(`URL: ${page.url()}\n\n${snapshot}`);
165
161
  }
166
162
 
167
- const content = await page.evaluate(() => {
168
- const c = document.body.cloneNode(true);
169
- c.querySelectorAll('script,style,noscript').forEach((e) => e.remove());
170
- return c.innerText || '';
171
- });
163
+ const content = await page.evaluate(extractPageText);
172
164
  const title = await page.title().catch(() => '');
173
165
  return text(`Title: ${title}\nURL: ${page.url()}\n\n${content.slice(0, 15000)}`);
174
166
  }
@@ -190,11 +182,26 @@ class McpServer {
190
182
  const isGoogle = args.engine === 'google';
191
183
 
192
184
  if (isGoogle) {
185
+ const { humanType, randomDelay } = await import('./humanize.js');
193
186
  await page.goto('https://www.google.com', { waitUntil: 'domcontentloaded', timeout: 15000 });
194
- await page.waitForTimeout(1000);
187
+ await randomDelay(800, 2000);
188
+
189
+ // Handle cookie consent (EU/UK)
190
+ try {
191
+ const consentBtn = page.locator(
192
+ 'button:has-text("Accept all"), button:has-text("Accept"), button:has-text("I agree")',
193
+ );
194
+ if (await consentBtn.first().isVisible({ timeout: 1500 })) {
195
+ await consentBtn.first().click({ timeout: 2000 });
196
+ await randomDelay(500, 1000);
197
+ }
198
+ } catch {}
199
+
200
+ // Human-like typing (consistent with CLI search command)
195
201
  try {
196
- await page.fill('textarea[name="q"], input[name="q"]', args.query);
197
- await page.keyboard.press('Enter');
202
+ await humanType(page, 'textarea[name="q"], input[name="q"]', args.query, {
203
+ pressEnter: true,
204
+ });
198
205
  } catch {
199
206
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
200
207
  }
@@ -284,7 +291,7 @@ class McpServer {
284
291
  result = {
285
292
  protocolVersion: '2024-11-05',
286
293
  capabilities: { tools: {} },
287
- serverInfo: { name: 'stealth-cli', version: '0.4.0' },
294
+ serverInfo: { name: 'stealth-cli', version: PKG_VERSION },
288
295
  };
289
296
  break;
290
297
 
package/src/profiles.js CHANGED
@@ -14,6 +14,7 @@ import fs from 'fs';
14
14
  import path from 'path';
15
15
  import os from 'os';
16
16
  import crypto from 'crypto';
17
+ import { ProfileError } from './errors.js';
17
18
 
18
19
  const PROFILES_DIR = path.join(os.homedir(), '.stealth', 'profiles');
19
20
 
@@ -148,7 +149,7 @@ export function createProfile(name, opts = {}) {
148
149
  const filePath = profilePath(name);
149
150
 
150
151
  if (fs.existsSync(filePath)) {
151
- throw new Error(`Profile "${name}" already exists. Use --force to overwrite.`);
152
+ throw new ProfileError(`Profile "${name}" already exists. Use --force to overwrite.`);
152
153
  }
153
154
 
154
155
  let fingerprint;
@@ -156,7 +157,7 @@ export function createProfile(name, opts = {}) {
156
157
  if (opts.preset) {
157
158
  fingerprint = FINGERPRINT_PRESETS[opts.preset];
158
159
  if (!fingerprint) {
159
- throw new Error(`Unknown preset "${opts.preset}". Available: ${Object.keys(FINGERPRINT_PRESETS).join(', ')}`);
160
+ throw new ProfileError(`Unknown preset "${opts.preset}". Available: ${Object.keys(FINGERPRINT_PRESETS).join(', ')}`, { hint: 'Run: stealth profile presets' });
160
161
  }
161
162
  fingerprint = { ...fingerprint }; // Clone
162
163
  } else if (opts.random || !opts.locale) {
@@ -193,7 +194,7 @@ export function loadProfile(name) {
193
194
  const filePath = profilePath(name);
194
195
 
195
196
  if (!fs.existsSync(filePath)) {
196
- throw new Error(`Profile "${name}" not found. Create with: stealth profile create ${name}`);
197
+ throw new ProfileError(`Profile "${name}" not found`, { hint: `Create with: stealth profile create ${name}` });
197
198
  }
198
199
 
199
200
  return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -286,7 +287,7 @@ export function listProfiles() {
286
287
  export function deleteProfile(name) {
287
288
  const filePath = profilePath(name);
288
289
  if (!fs.existsSync(filePath)) {
289
- throw new Error(`Profile "${name}" not found`);
290
+ throw new ProfileError(`Profile "${name}" not found`);
290
291
  }
291
292
  fs.unlinkSync(filePath);
292
293
  }
package/src/proxy-pool.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
+ import { ProxyError } from './errors.js';
10
11
 
11
12
  const STEALTH_DIR = path.join(os.homedir(), '.stealth');
12
13
  const PROXIES_FILE = path.join(STEALTH_DIR, 'proxies.json');
@@ -25,7 +26,10 @@ function loadData() {
25
26
 
26
27
  function saveData(data) {
27
28
  ensureDir();
28
- fs.writeFileSync(PROXIES_FILE, JSON.stringify(data, null, 2));
29
+ // Atomic write: write to temp file then rename (prevents corruption on crash)
30
+ const tmpPath = PROXIES_FILE + '.tmp';
31
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
32
+ fs.renameSync(tmpPath, PROXIES_FILE);
29
33
  }
30
34
 
31
35
  /**
@@ -158,8 +162,7 @@ export function reportProxy(proxyUrl, success, latencyMs = null) {
158
162
  * Test a proxy by making a request
159
163
  */
160
164
  export async function testProxy(proxyUrl) {
161
- const { launchOptions } = await import('camoufox-js');
162
- const { firefox } = await import('playwright-core');
165
+ const { createBrowser } = await import('./utils/browser-factory.js');
163
166
 
164
167
  const start = Date.now();
165
168
  let browser;
@@ -175,15 +178,10 @@ export async function testProxy(proxyUrl) {
175
178
  password: url.password || undefined,
176
179
  };
177
180
  } catch {
178
- throw new Error(`Invalid proxy URL: ${proxyUrl}`);
181
+ throw new ProxyError(proxyUrl, new Error('Invalid proxy URL format'));
179
182
  }
180
183
 
181
- const options = await launchOptions({
182
- headless: true,
183
- proxy: proxyConfig,
184
- });
185
-
186
- browser = await firefox.launch(options);
184
+ browser = await createBrowser({ headless: true, proxy: proxyConfig });
187
185
  const context = await browser.newContext();
188
186
  const page = await context.newPage();
189
187
 
package/src/session.js CHANGED
@@ -66,12 +66,12 @@ export async function captureSession(name, context, page, opts = {}) {
66
66
  // Save cookies
67
67
  try {
68
68
  session.cookies = await context.cookies();
69
- } catch {}
69
+ } catch { /* context may be closed */ }
70
70
 
71
71
  // Save current URL
72
72
  try {
73
73
  session.lastUrl = page.url();
74
- } catch {}
74
+ } catch { /* page may be closed */ }
75
75
 
76
76
  // Append to history
77
77
  if (session.lastUrl && session.lastUrl !== 'about:blank') {
@@ -113,7 +113,7 @@ export async function restoreSession(name, context) {
113
113
  if (validCookies.length > 0) {
114
114
  await context.addCookies(validCookies);
115
115
  }
116
- } catch {}
116
+ } catch { /* cookies may have invalid format */ }
117
117
  }
118
118
 
119
119
  return {