surf-cli 2.18.0 → 2.20.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.
@@ -75,6 +75,58 @@ function assertNotAborted(signal) {
75
75
  if (message) throw new Error(message);
76
76
  }
77
77
 
78
+ const SEMANTIC_SUCCESS_STATUSES = new Set(["completed", "verified", "skipped_already_satisfied"]);
79
+
80
+ function semanticFailure(result) {
81
+ if (!result || typeof result !== "object") return "semantic executor returned an invalid result";
82
+ if (typeof result.error === "string" && result.error) return result.error;
83
+ if (typeof result.reason === "string" && result.reason) return result.reason;
84
+ return `semantic step did not succeed (status: ${String(result.status || "missing")})`;
85
+ }
86
+
87
+ function semanticDetail(result, step) {
88
+ if (result?.semantic) return result.semantic;
89
+ if (!result || typeof result !== "object") return undefined;
90
+ const detail = {
91
+ ...(result.runId ? { runId: result.runId } : {}),
92
+ ...(step.id ? { stepId: step.id } : {}),
93
+ ...(result.reason ? { reason: result.reason } : {}),
94
+ ...(result.write ? { write: result.write } : {}),
95
+ ...(result.coverage ? { coverage: result.coverage } : {}),
96
+ ...(result.checkpoint ? { checkpoint: result.checkpoint } : {}),
97
+ ...(result.usage ? { usage: result.usage } : {}),
98
+ ...(result.limits ? { limits: result.limits } : {}),
99
+ };
100
+ return Object.keys(detail).length ? detail : undefined;
101
+ }
102
+
103
+ async function executeSemanticStep(step, semanticContext, options) {
104
+ const { executeSemanticStep: executor, onEvent = () => {}, signal } = options;
105
+ if (typeof executor !== "function") throw new Error("semantic.step requires an injected semantic executor");
106
+ const startedAt = new Date().toISOString();
107
+ const baseEvent = { command: step.cmd, argsRedacted: redactCommandArgs(step.cmd, step.args || {}, false), startedAt };
108
+ onEvent({ type: "tool.started", ...baseEvent });
109
+ try {
110
+ assertNotAborted(signal);
111
+ const result = await executor(step, semanticContext, { signal });
112
+ assertNotAborted(signal);
113
+ if (!SEMANTIC_SUCCESS_STATUSES.has(result?.status)) {
114
+ const error = semanticFailure(result);
115
+ onEvent({ type: "tool.failed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: error });
116
+ const semantic = semanticDetail(result, step);
117
+ return { success: false, error, ...(semantic ? { semantic } : {}) };
118
+ }
119
+ const output = result.publicResult;
120
+ const semantic = semanticDetail(result, step);
121
+ onEvent({ type: "tool.completed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: result.status });
122
+ return { success: true, ...(output !== undefined ? { output } : {}), ...(semantic ? { semantic } : {}) };
123
+ } catch (error) {
124
+ const message = error?.message || String(error);
125
+ onEvent({ type: "tool.failed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: message });
126
+ return { success: false, error: message };
127
+ }
128
+ }
129
+
78
130
  async function executeSingleStep(step, vars, options) {
79
131
  const {
80
132
  autoWait = true,
@@ -126,6 +178,11 @@ async function executeSingleStep(step, vars, options) {
126
178
  async function executeStep(step, vars, options) {
127
179
  const { onError = "stop" } = options;
128
180
  assertNotAborted(options.signal);
181
+ if (step.cmd === "semantic.step") {
182
+ const result = await executeSemanticStep(step, options.semanticContext, options);
183
+ if (result.success && step.as && result.output !== undefined) vars[step.as] = result.output;
184
+ return { ...result, stepsExecuted: 1 };
185
+ }
129
186
  if (step.repeat !== undefined) {
130
187
  let max = resolveVar(step.repeat, vars);
131
188
  if (typeof max === "string") max = Number.parseInt(max, 10);
@@ -177,9 +234,16 @@ function copyCapturedVars(steps, source, target) {
177
234
  }
178
235
 
179
236
  async function executeWorkflow(steps, options = {}) {
237
+ const semanticEnabled = steps.some((step) => step.cmd === "semantic.step");
238
+ if (semanticEnabled && (options.onError || "stop") !== "stop") {
239
+ return { status: "failed", completedSteps: 0, totalSteps: steps.length, results: [], error: "semantic workflows require onError='stop'", totalMs: 0, vars: { ...(options.vars || {}), ...(options.context?.vars || {}) } };
240
+ }
180
241
  const vars = { ...(options.vars || {}), ...(options.context?.vars || {}) };
242
+ const semanticContext = semanticEnabled ? (options.createSemanticContext?.() || Object.create(null)) : undefined;
243
+ const executionOptions = semanticEnabled ? { ...options, semanticContext } : options;
181
244
  const results = [];
182
245
  let failed = 0;
246
+ let semantic;
183
247
  let stepsExecuted = 0;
184
248
  const startTotal = Date.now();
185
249
  for (let index = 0; index < steps.length; index++) {
@@ -189,25 +253,26 @@ async function executeWorkflow(steps, options = {}) {
189
253
  options.onProgress?.({ phase: "start", index, total: steps.length, step, type });
190
254
  let result;
191
255
  try {
192
- result = await executeStep(step, vars, options);
256
+ result = await executeStep(step, vars, executionOptions);
193
257
  } catch (error) {
194
258
  result = { success: false, error: error?.message || String(error), stepsExecuted: 0 };
195
259
  }
196
260
  const ms = Date.now() - startTime;
261
+ if (result.semantic) semantic = result.semantic;
197
262
  stepsExecuted += type === "loop" ? result.stepsExecuted || 0 : 1;
198
263
  if (!result.success) {
199
264
  failed++;
200
- results.push({ step: index + 1, ...(type === "loop" ? { type: "loop" } : { cmd: step.cmd }), status: "error", error: result.error, ms });
265
+ results.push({ step: index + 1, ...(type === "loop" ? { type: "loop" } : { cmd: step.cmd }), status: "error", error: result.error, ...(result.semantic ? { semantic: result.semantic } : {}), ms });
201
266
  options.onProgress?.({ phase: "fail", index, total: steps.length, step, type, ms, error: result.error });
202
267
  if ((options.onError || "stop") === "stop") {
203
- return { status: "failed", completedSteps: type === "loop" ? stepsExecuted : stepsExecuted - 1, totalSteps: steps.length, results, error: result.error, totalMs: Date.now() - startTotal, vars };
268
+ return { status: "failed", completedSteps: type === "loop" ? stepsExecuted : stepsExecuted - 1, totalSteps: steps.length, results, error: result.error, ...(semantic ? { semantic } : {}), totalMs: Date.now() - startTotal, vars };
204
269
  }
205
270
  } else {
206
- results.push({ step: index + 1, ...(type === "loop" ? { type: "loop", stepsExecuted: result.stepsExecuted } : { cmd: step.cmd }), status: "ok", ms });
271
+ results.push({ step: index + 1, ...(type === "loop" ? { type: "loop", stepsExecuted: result.stepsExecuted } : { cmd: step.cmd }), status: "ok", ...(result.semantic ? { semantic: result.semantic } : {}), ms });
207
272
  options.onProgress?.({ phase: "ok", index, total: steps.length, step, type, ms, stepsExecuted: result.stepsExecuted });
208
273
  }
209
274
  }
210
- return { status: failed > 0 ? "partial" : "completed", completedSteps: stepsExecuted, totalSteps: steps.length, results, failed, totalMs: Date.now() - startTotal, vars };
275
+ return { status: failed > 0 ? "partial" : "completed", completedSteps: stepsExecuted, totalSteps: steps.length, results, failed, ...(semantic ? { semantic } : {}), totalMs: Date.now() - startTotal, vars };
211
276
  }
212
277
 
213
278
  module.exports = {
@@ -215,6 +280,7 @@ module.exports = {
215
280
  AUTO_WAIT_MAP,
216
281
  MAX_LOOP_ITERATIONS,
217
282
  executeSingleStep,
283
+ executeSemanticStep,
218
284
  executeStep,
219
285
  executeWorkflow,
220
286
  extractStepOutput,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -41,13 +41,16 @@
41
41
  "scripts": {
42
42
  "dev": "vite build --watch --mode development",
43
43
  "build": "vite build",
44
+ "prepack": "npm run build",
44
45
  "check": "tsc --noEmit && tsc --noEmit -p tsconfig.pi-extension.json",
45
46
  "lint": "biome check .",
46
47
  "lint:fix": "biome check --write .",
47
48
  "lint:test": "biome check test/",
48
49
  "format": "biome format --write .",
49
50
  "test": "vitest run",
51
+ "test:package": "node test/package-artifact.mjs",
50
52
  "test:e2e:chrome": "node test/e2e/real-chrome.mjs",
53
+ "eval:jev": "node test/eval/real-jev.mjs",
51
54
  "test:watch": "vitest",
52
55
  "test:coverage": "vitest run --coverage",
53
56
  "test:ui": "vitest --ui",
@@ -57,6 +60,7 @@
57
60
  "dependencies": {
58
61
  "@google/generative-ai": "^0.24.1",
59
62
  "@modelcontextprotocol/sdk": "^1.26.0",
63
+ "@typesafe-ai/sdk": "0.6.0",
60
64
  "buffer": "^6.0.3",
61
65
  "crypto-browserify": "^3.12.1",
62
66
  "events": "^3.3.0",
@@ -66,16 +70,16 @@
66
70
  },
67
71
  "devDependencies": {
68
72
  "@biomejs/biome": "^2.5.4",
69
- "@types/chrome": "^0.2.2",
73
+ "@types/chrome": "^0.3.0",
70
74
  "@types/node": "^26.1.2",
71
- "@vitest/coverage-v8": "^4.1.9",
72
- "@vitest/ui": "^4.1.9",
73
- "pi-subagents": "^0.58.0",
74
- "puppeteer": "25.9.0",
75
+ "@vitest/coverage-v8": "^5.0.0",
76
+ "@vitest/ui": "^5.0.0",
77
+ "pi-subagents": "^0.69.0",
78
+ "puppeteer": "25.11.0",
75
79
  "typebox": "^1.3.11",
76
80
  "typescript": "^7.0.2",
77
81
  "vite": "^8.1.4",
78
- "vitest": "^4.1.9"
82
+ "vitest": "^5.0.0"
79
83
  },
80
84
  "pi": {
81
85
  "extensions": [
@@ -3,9 +3,20 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
5
  const { execFileSync, execSync } = require("child_process");
6
+ const {
7
+ convertWindowsPath,
8
+ convertWslPath,
9
+ getWindowsEnv,
10
+ nativeMessagingRegistryPath,
11
+ runWindowsExecutable,
12
+ } = require("./windows-interop.cjs");
6
13
  const { parseListenEndpoint } = require("../native/listener.cjs");
7
14
  const { normalizeSocketConfig } = require("../native/socket-permissions.cjs");
8
15
  const { getStateDir, loadHostIdentity, loadRegistry } = require("../native/remote-auth.cjs");
16
+ const {
17
+ renderWslWrapper,
18
+ probeWindowsWrapper,
19
+ } = require("../native/native-host-launch-probe.cjs");
9
20
 
10
21
  const HOST_NAME = "surf.browser.host";
11
22
 
@@ -111,8 +122,7 @@ function getWrapperDir(target = process.platform) {
111
122
  const home = os.homedir();
112
123
  if (target === "wsl-windows") {
113
124
  const localAppData = getWindowsEnv("LOCALAPPDATA");
114
- if (!localAppData) return null;
115
- return path.join(windowsPathToWslPath(localAppData), "surf-cli");
125
+ return path.join(convertWindowsPath(localAppData), "surf-cli");
116
126
  }
117
127
  switch (process.platform) {
118
128
  case "darwin":
@@ -140,44 +150,21 @@ function getHostPath() {
140
150
  return null;
141
151
  }
142
152
 
143
- function getWindowsEnv(name) {
144
- try {
145
- return execFileSync("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
146
- .trim()
147
- .replace(/\r/g, "");
148
- } catch {
149
- return null;
150
- }
151
- }
152
-
153
- function windowsPathToWslPath(winPath) {
154
- const normalized = winPath.replace(/\\/g, "/");
155
- const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
156
- if (!match) return normalized;
157
- return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
158
- }
159
-
160
153
  function wslPathToWindowsPath(wslPath) {
161
- try {
162
- return execFileSync("wslpath", ["-w", wslPath], { encoding: "utf8" }).trim().replace(/\r/g, "");
163
- } catch {
164
- const match = wslPath.match(/^\/mnt\/([a-zA-Z])\/(.*)$/);
165
- if (match) return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, "\\")}`;
166
- return wslPath;
167
- }
154
+ if (!isWsl()) return wslPath;
155
+ return convertWslPath(wslPath);
168
156
  }
169
157
 
170
- function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen, socketMode, socketGroup) {
158
+ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen, socketMode, socketGroup, distro = process.env.WSL_DISTRO_NAME, convertPath = wslPathToWindowsPath) {
171
159
  const socketConfig = normalizeSocketConfig(socketMode, socketGroup);
172
160
  assertSocketAccessTargetSupported(socketConfig.mode, socketConfig.group, target);
173
161
  fs.mkdirSync(wrapperDir, { recursive: true });
174
162
 
175
163
  if (target === "wsl-windows") {
176
164
  const cmdPath = path.join(wrapperDir, "host-wrapper-wsl.cmd");
177
- const distroArg = process.env.WSL_DISTRO_NAME ? ` -d "${process.env.WSL_DISTRO_NAME}"` : "";
178
- const content = `@echo off\r\nwsl.exe${distroArg} --cd "${path.dirname(hostPath)}" --exec "${nodePath}" "${hostPath}" %*\r\n`;
179
- fs.writeFileSync(cmdPath, content);
180
- return wslPathToWindowsPath(cmdPath);
165
+ const windowsPath = convertPath(cmdPath);
166
+ fs.writeFileSync(cmdPath, renderWslWrapper(nodePath, hostPath, distro));
167
+ return windowsPath;
181
168
  }
182
169
 
183
170
  if (process.platform === "win32") {
@@ -202,6 +189,32 @@ ${listen ? `: "\${SURF_LISTEN:=${listen}}"\nexport SURF_LISTEN\n` : ""}${socketE
202
189
  return shPath;
203
190
  }
204
191
 
192
+ function installWithValidatedWrapper(wrapperPath, target, install, deps = {}) {
193
+ if (target === "wsl-windows") {
194
+ if (!deps.distro) throw new Error("WSL_DISTRO_NAME is required for Windows browser installation");
195
+ try {
196
+ probeWindowsWrapper(wrapperPath, deps);
197
+ } catch (error) {
198
+ if (!error.message.includes("WSL_E_DISTRO_NOT_FOUND")) {
199
+ throw new Error(`WSL wrapper validation failed before registration: ${error.message}`);
200
+ }
201
+ const explicitFailure = error;
202
+ const original = fs.readFileSync(deps.wrapperFsPath, "utf8");
203
+ fs.writeFileSync(deps.wrapperFsPath, renderWslWrapper(deps.nodePath, deps.hostPath, null));
204
+ try {
205
+ const defaultDistro = probeWindowsWrapper(wrapperPath, { ...deps, verifyDistro: true });
206
+ if (defaultDistro !== deps.distro) {
207
+ throw new Error("Windows default WSL distro does not match the installing distro");
208
+ }
209
+ } catch (fallbackError) {
210
+ fs.writeFileSync(deps.wrapperFsPath, original);
211
+ throw new Error(`WSL wrapper validation failed before registration (explicit distro: ${explicitFailure.message}; default distro: ${fallbackError.message})`);
212
+ }
213
+ }
214
+ }
215
+ return install();
216
+ }
217
+
205
218
  function assertListenTargetSupported(listen, target) {
206
219
  if (listen && (target === "win32" || target === "wsl-windows")) {
207
220
  throw new Error("--listen is not supported for Windows native-host wrappers");
@@ -238,22 +251,24 @@ function writeManifest(manifestPath, extensionId, wrapperPath) {
238
251
  return manifestPath;
239
252
  }
240
253
 
241
- function getWslWindowsManifestDir(browserConfig) {
242
- const localAppData = getWindowsEnv("LOCALAPPDATA");
243
- if (!localAppData || !browserConfig.wsl) return null;
244
- return path.join(windowsPathToWslPath(localAppData), browserConfig.wsl);
254
+ function getWslWindowsManifestDir(browserConfig, deps = {}) {
255
+ if (!browserConfig.wsl) return null;
256
+ const localAppData = getWindowsEnv("LOCALAPPDATA", deps);
257
+ return path.join(convertWindowsPath(localAppData, deps), browserConfig.wsl);
245
258
  }
246
259
 
247
- function installManifest(browser, extensionId, wrapperPath, target) {
260
+ function installManifest(browser, extensionId, wrapperPath, target, deps = {}) {
248
261
  const browserConfig = BROWSERS[browser];
249
262
 
250
263
  if (!browserConfig) return null;
251
264
 
252
265
  if (target === "wsl-windows") {
253
- const manifestDir = getWslWindowsManifestDir(browserConfig);
266
+ const manifestDir = getWslWindowsManifestDir(browserConfig, deps);
254
267
  if (!manifestDir) return null;
255
268
  const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
256
- return writeManifest(manifestPath, extensionId, wrapperPath);
269
+ writeManifest(manifestPath, extensionId, wrapperPath);
270
+ addWindowsRegistry(browser, convertWslPath(manifestPath, deps), true, deps);
271
+ return manifestPath;
257
272
  }
258
273
 
259
274
  const platform = process.platform;
@@ -268,23 +283,27 @@ function installManifest(browser, extensionId, wrapperPath, target) {
268
283
  return writeManifest(manifestPath, extensionId, wrapperPath);
269
284
  }
270
285
 
286
+ function addWindowsRegistry(browser, manifestPath, allowWslFallback = false, deps = {}) {
287
+ const browserConfig = BROWSERS[browser];
288
+ const regPath = nativeMessagingRegistryPath(browserConfig.win32, HOST_NAME);
289
+
290
+ runWindowsExecutable("reg.exe", ["add", regPath, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"], {
291
+ execFileSync: deps.execFileSync || execFileSync,
292
+ allowWslFallback,
293
+ execOptions: { stdio: "pipe", encoding: "utf8" },
294
+ });
295
+ return regPath;
296
+ }
297
+
271
298
  function installWindowsRegistry(browser, extensionId, wrapperPath) {
272
299
  const browserConfig = BROWSERS[browser];
273
- const regPath = `HKCU\\Software\\${browserConfig.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
300
+ if (!browserConfig.win32) return null;
274
301
 
275
302
  const manifestDir = getWrapperDir();
276
303
  const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
277
304
  writeManifest(manifestPath, extensionId, wrapperPath);
278
-
279
- try {
280
- execSync(`reg add "${regPath}" /ve /t REG_SZ /d "${manifestPath}" /f`, {
281
- stdio: "pipe",
282
- });
283
- return manifestPath;
284
- } catch (e) {
285
- console.error(`Failed to add registry entry: ${e.message}`);
286
- return null;
287
- }
305
+ addWindowsRegistry(browser, manifestPath);
306
+ return manifestPath;
288
307
  }
289
308
 
290
309
  function parseArgs() {
@@ -445,6 +464,9 @@ function main() {
445
464
  console.log(`Wrapper dir: ${wrapperDir}`);
446
465
  console.log("");
447
466
 
467
+ const wrapperFsPath = path.join(wrapperDir, "host-wrapper-wsl.cmd");
468
+ const previousWrapper = effectiveTarget === "wsl-windows" && fs.existsSync(wrapperFsPath)
469
+ ? fs.readFileSync(wrapperFsPath, "utf8") : null;
448
470
  const wrapperPath = createWrapper(
449
471
  wrapperDir,
450
472
  nodePath,
@@ -460,18 +482,34 @@ function main() {
460
482
  const installed = [];
461
483
  const skipped = [];
462
484
 
463
- for (const browser of browsers) {
464
- if (!BROWSERS[browser]) {
465
- console.error(`Unknown browser: ${browser}`);
466
- continue;
467
- }
468
-
469
- const result = installManifest(browser, extensionId, wrapperPath, effectiveTarget);
470
- if (result) {
471
- installed.push({ browser: BROWSERS[browser].name, path: result });
472
- } else {
473
- skipped.push(BROWSERS[browser].name);
485
+ try {
486
+ installWithValidatedWrapper(wrapperPath, effectiveTarget, () => {
487
+ for (const browser of browsers) {
488
+ if (!BROWSERS[browser]) {
489
+ console.error(`Unknown browser: ${browser}`);
490
+ continue;
491
+ }
492
+
493
+ let result;
494
+ try {
495
+ result = installManifest(browser, extensionId, wrapperPath, effectiveTarget);
496
+ } catch (error) {
497
+ throw new Error(`Failed to install ${BROWSERS[browser].name}: ${error.message}`);
498
+ }
499
+ if (result) {
500
+ installed.push({ browser: BROWSERS[browser].name, path: result });
501
+ } else {
502
+ skipped.push(BROWSERS[browser].name);
503
+ }
504
+ }
505
+ }, { wrapperFsPath, nodePath, hostPath, distro: process.env.WSL_DISTRO_NAME });
506
+ } catch (error) {
507
+ if (effectiveTarget === "wsl-windows" && installed.length === 0) {
508
+ if (previousWrapper === null) fs.rmSync(wrapperFsPath, { force: true });
509
+ else fs.writeFileSync(wrapperFsPath, previousWrapper);
474
510
  }
511
+ console.error(`Error: ${error.message}`);
512
+ process.exit(1);
475
513
  }
476
514
 
477
515
  if (installed.length > 0) {
@@ -494,7 +532,12 @@ if (require.main === module) {
494
532
 
495
533
  module.exports = {
496
534
  createWrapper,
535
+ findNode,
536
+ getHostPath,
537
+ probeWindowsWrapper,
538
+ installWithValidatedWrapper,
497
539
  writeManifest,
498
540
  assertListenTargetSupported,
499
541
  assertSocketAccessTargetSupported,
542
+ installManifest,
500
543
  };
@@ -2,7 +2,13 @@
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
- const { execFileSync, execSync } = require("child_process");
5
+ const { execFileSync } = require("child_process");
6
+ const {
7
+ convertWindowsPath,
8
+ getWindowsEnv,
9
+ nativeMessagingRegistryPath,
10
+ runWindowsExecutable,
11
+ } = require("./windows-interop.cjs");
6
12
 
7
13
  const HOST_NAME = "surf.browser.host";
8
14
 
@@ -61,29 +67,11 @@ function isWsl() {
61
67
  }
62
68
  }
63
69
 
64
- function getWindowsEnv(name) {
65
- try {
66
- return execFileSync("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
67
- .trim()
68
- .replace(/\r/g, "");
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- function windowsPathToWslPath(winPath) {
75
- const normalized = winPath.replace(/\\/g, "/");
76
- const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
77
- if (!match) return normalized;
78
- return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
79
- }
80
-
81
70
  function getWrapperDir(target = process.platform) {
82
71
  const home = os.homedir();
83
72
  if (target === "wsl-windows") {
84
73
  const localAppData = getWindowsEnv("LOCALAPPDATA");
85
- if (!localAppData) return null;
86
- return path.join(windowsPathToWslPath(localAppData), "surf-cli");
74
+ return path.join(convertWindowsPath(localAppData), "surf-cli");
87
75
  }
88
76
  switch (process.platform) {
89
77
  case "darwin":
@@ -97,25 +85,27 @@ function getWrapperDir(target = process.platform) {
97
85
  }
98
86
  }
99
87
 
100
- function getWslWindowsManifestPath(browserConfig) {
101
- const localAppData = getWindowsEnv("LOCALAPPDATA");
102
- if (!localAppData || !browserConfig.wsl) return null;
103
- return path.join(windowsPathToWslPath(localAppData), browserConfig.wsl, `${HOST_NAME}.json`);
88
+ function getWslWindowsManifestPath(browserConfig, deps = {}) {
89
+ if (!browserConfig.wsl) return null;
90
+ const localAppData = getWindowsEnv("LOCALAPPDATA", deps);
91
+ return path.join(convertWindowsPath(localAppData, deps), browserConfig.wsl, `${HOST_NAME}.json`);
104
92
  }
105
93
 
106
- function removeManifest(browser, target) {
94
+ function removeManifest(browser, target, deps = {}) {
107
95
  const browserConfig = BROWSERS[browser];
108
96
 
109
97
  if (!browserConfig) return null;
110
98
 
111
99
  if (target === "wsl-windows") {
112
- const manifestPath = getWslWindowsManifestPath(browserConfig);
100
+ const manifestPath = getWslWindowsManifestPath(browserConfig, deps);
113
101
  if (!manifestPath) return null;
102
+ removeWindowsRegistry(browser, true, deps);
114
103
  try {
115
- fs.unlinkSync(manifestPath);
104
+ (deps.fs || fs).unlinkSync(manifestPath);
116
105
  return manifestPath;
117
- } catch {
118
- return null;
106
+ } catch (error) {
107
+ if (error?.code === "ENOENT") return null;
108
+ throw new Error(`Failed to remove manifest ${manifestPath}: ${error.message || String(error)}`);
119
109
  }
120
110
  }
121
111
 
@@ -140,16 +130,20 @@ function removeManifest(browser, target) {
140
130
  }
141
131
  }
142
132
 
143
- function removeWindowsRegistry(browser) {
133
+ function removeWindowsRegistry(browser, allowWslFallback = false, deps = {}) {
144
134
  const browserConfig = BROWSERS[browser];
145
- const regPath = `HKCU\\Software\\${browserConfig.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
146
-
135
+ const regPath = nativeMessagingRegistryPath(browserConfig.win32, HOST_NAME);
147
136
  try {
148
- execSync(`reg delete "${regPath}" /f`, { stdio: "pipe" });
149
- return regPath;
150
- } catch {
151
- return null;
137
+ runWindowsExecutable("reg.exe", ["delete", regPath, "/f"], {
138
+ execFileSync: deps.execFileSync || execFileSync,
139
+ allowWslFallback,
140
+ execOptions: { stdio: "pipe", encoding: "utf8" },
141
+ });
142
+ } catch (error) {
143
+ if (/unable to find the specified registry key or value/i.test(error.message)) return null;
144
+ throw error;
152
145
  }
146
+ return regPath;
153
147
  }
154
148
 
155
149
  function removeWrapperDir(target) {
@@ -246,7 +240,13 @@ function main() {
246
240
  continue;
247
241
  }
248
242
 
249
- const result = removeManifest(browser, effectiveTarget);
243
+ let result;
244
+ try {
245
+ result = removeManifest(browser, effectiveTarget);
246
+ } catch (error) {
247
+ console.error(`Error: Failed to uninstall ${BROWSERS[browser].name}: ${error.message}`);
248
+ process.exit(1);
249
+ }
250
250
  if (result) {
251
251
  removed.push({ browser: BROWSERS[browser].name, path: result });
252
252
  } else {
@@ -275,4 +275,6 @@ function main() {
275
275
  console.log("\nDone!");
276
276
  }
277
277
 
278
- main();
278
+ if (require.main === module) main();
279
+
280
+ module.exports = { removeManifest };
@@ -0,0 +1,89 @@
1
+ const { execFileSync } = require("child_process");
2
+
3
+ function errorDetail(error) {
4
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
5
+ return stderr || error.message || String(error);
6
+ }
7
+
8
+ function runWindowsExecutable(executable, args, options = {}) {
9
+ const execFile = options.execFileSync || execFileSync;
10
+ const execOptions = options.execOptions || { encoding: "utf8" };
11
+
12
+ try {
13
+ return execFile(executable, args, execOptions);
14
+ } catch (directError) {
15
+ if (!options.allowWslFallback || directError?.code !== "ENOENT") {
16
+ throw new Error(`Failed to run ${executable}: ${errorDetail(directError)}`);
17
+ }
18
+
19
+ const windowsPath = `C:\\Windows\\System32\\${executable}`;
20
+ let resolvedPath;
21
+ try {
22
+ resolvedPath = execFile("wslpath", ["-u", windowsPath], { encoding: "utf8" }).trim();
23
+ } catch (resolveError) {
24
+ throw new Error(
25
+ `Could not find ${executable} (bare lookup: ${errorDetail(directError)}; ` +
26
+ `wslpath ${windowsPath}: ${errorDetail(resolveError)})`,
27
+ );
28
+ }
29
+ if (!resolvedPath) {
30
+ throw new Error(`Could not find ${executable}: wslpath returned an empty path for ${windowsPath}`);
31
+ }
32
+
33
+ try {
34
+ return execFile(resolvedPath, args, execOptions);
35
+ } catch (resolvedError) {
36
+ throw new Error(
37
+ `Failed to run ${executable} at ${resolvedPath}: ${errorDetail(resolvedError)}`,
38
+ );
39
+ }
40
+ }
41
+ }
42
+
43
+ function getWindowsEnv(name, options = {}) {
44
+ const value = runWindowsExecutable("cmd.exe", ["/c", "echo", `%${name}%`], {
45
+ execFileSync: options.execFileSync || execFileSync,
46
+ allowWslFallback: true,
47
+ execOptions: { encoding: "utf8" },
48
+ }).trim();
49
+ if (!value || value === `%${name}%`) {
50
+ throw new Error(
51
+ `Windows environment variable ${name} is unavailable (cmd.exe returned ${JSON.stringify(value)})`,
52
+ );
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function nativeMessagingRegistryPath(browserRegistryRoot, hostName) {
58
+ return `HKCU\\Software\\${browserRegistryRoot}\\NativeMessagingHosts\\${hostName}`;
59
+ }
60
+
61
+ function convertWindowsPath(windowsPath, options = {}) {
62
+ const execFile = options.execFileSync || execFileSync;
63
+ try {
64
+ const converted = execFile("wslpath", ["-u", windowsPath], { encoding: "utf8" }).trim();
65
+ if (!converted) throw new Error("wslpath returned an empty path");
66
+ return converted;
67
+ } catch (error) {
68
+ throw new Error(`Could not convert Windows path ${windowsPath}: ${errorDetail(error)}`);
69
+ }
70
+ }
71
+
72
+ function convertWslPath(wslPath, options = {}) {
73
+ const execFile = options.execFileSync || execFileSync;
74
+ try {
75
+ const converted = execFile("wslpath", ["-w", wslPath], { encoding: "utf8" }).trim();
76
+ if (!converted) throw new Error("wslpath returned an empty path");
77
+ return converted;
78
+ } catch (error) {
79
+ throw new Error(`Could not convert WSL path ${wslPath}: ${errorDetail(error)}`);
80
+ }
81
+ }
82
+
83
+ module.exports = {
84
+ convertWindowsPath,
85
+ convertWslPath,
86
+ getWindowsEnv,
87
+ nativeMessagingRegistryPath,
88
+ runWindowsExecutable,
89
+ };