surf-cli 2.7.2 → 2.9.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 +208 -13
- package/dist/content/index.js +116 -0
- package/dist/content/index.js.map +1 -0
- package/dist/manifest.json +2 -11
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +261 -61
- package/dist/service-worker/index.js.map +1 -1
- package/native/abort.cjs +65 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +169 -0
- package/native/chatgpt-client.cjs +63 -30
- package/native/cli.cjs +947 -460
- package/native/client-transport.cjs +168 -0
- package/native/config.cjs +2 -2
- package/native/do-executor.cjs +25 -51
- package/native/do-parser.cjs +12 -0
- package/native/doctor.cjs +633 -0
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +244 -88
- package/native/grok-client.cjs +321 -212
- package/native/host-helpers.cjs +88 -16
- package/native/host-sessions.cjs +283 -0
- package/native/host.cjs +811 -616
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -62
- package/native/network-export.cjs +113 -0
- package/native/perplexity-client.cjs +46 -17
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +46 -0
- package/package.json +11 -9
- package/scripts/install-native-host.cjs +184 -51
- package/scripts/uninstall-native-host.cjs +93 -15
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +77 -22
- package/dist/content/accessibility-tree.js +0 -11
- package/dist/content/accessibility-tree.js.map +0 -1
- package/dist/content/visual-indicator.js +0 -111
- package/dist/content/visual-indicator.js.map +0 -1
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const net = require("net");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { execFileSync } = require("child_process");
|
|
6
|
+
const { connectEndpoint, selectEndpoint } = require("./endpoint.cjs");
|
|
7
|
+
|
|
8
|
+
const HOST_NAME = "surf.browser.host";
|
|
9
|
+
|
|
10
|
+
const BROWSERS = {
|
|
11
|
+
chrome: {
|
|
12
|
+
name: "Google Chrome",
|
|
13
|
+
darwin: "Library/Application Support/Google/Chrome/NativeMessagingHosts",
|
|
14
|
+
linux: ".config/google-chrome/NativeMessagingHosts",
|
|
15
|
+
win32: "Google\\Chrome",
|
|
16
|
+
wsl: "Google/Chrome/User Data/NativeMessagingHosts",
|
|
17
|
+
},
|
|
18
|
+
chromium: {
|
|
19
|
+
name: "Chromium",
|
|
20
|
+
darwin: "Library/Application Support/Chromium/NativeMessagingHosts",
|
|
21
|
+
linux: ".config/chromium/NativeMessagingHosts",
|
|
22
|
+
win32: "Chromium",
|
|
23
|
+
wsl: "Chromium/User Data/NativeMessagingHosts",
|
|
24
|
+
},
|
|
25
|
+
brave: {
|
|
26
|
+
name: "Brave",
|
|
27
|
+
darwin: "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts",
|
|
28
|
+
linux: ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",
|
|
29
|
+
win32: "BraveSoftware\\Brave-Browser",
|
|
30
|
+
wsl: "BraveSoftware/Brave-Browser/User Data/NativeMessagingHosts",
|
|
31
|
+
},
|
|
32
|
+
edge: {
|
|
33
|
+
name: "Microsoft Edge",
|
|
34
|
+
darwin: "Library/Application Support/Microsoft Edge/NativeMessagingHosts",
|
|
35
|
+
linux: ".config/microsoft-edge/NativeMessagingHosts",
|
|
36
|
+
win32: "Microsoft\\Edge",
|
|
37
|
+
wsl: "Microsoft/Edge/User Data/NativeMessagingHosts",
|
|
38
|
+
},
|
|
39
|
+
arc: {
|
|
40
|
+
name: "Arc",
|
|
41
|
+
darwin: "Library/Application Support/Arc/User Data/NativeMessagingHosts",
|
|
42
|
+
linux: null,
|
|
43
|
+
win32: null,
|
|
44
|
+
wsl: null,
|
|
45
|
+
},
|
|
46
|
+
helium: {
|
|
47
|
+
name: "Helium",
|
|
48
|
+
darwin: "Library/Application Support/net.imput.helium/NativeMessagingHosts",
|
|
49
|
+
linux: null,
|
|
50
|
+
win32: null,
|
|
51
|
+
wsl: null,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function isWsl({ platform = process.platform, env = process.env, readFileSync = fs.readFileSync } = {}) {
|
|
56
|
+
if (platform !== "linux") return false;
|
|
57
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
58
|
+
try {
|
|
59
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultSocketPath(platform = process.platform) {
|
|
66
|
+
return platform === "win32" ? "//./pipe/surf" : "/tmp/surf.sock";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseDoctorArgs(rawArgs) {
|
|
70
|
+
const options = {
|
|
71
|
+
browser: "chrome",
|
|
72
|
+
target: "auto",
|
|
73
|
+
json: false,
|
|
74
|
+
socket: undefined,
|
|
75
|
+
connectTimeoutMs: 750,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
79
|
+
const arg = rawArgs[i];
|
|
80
|
+
if (arg === "--json") {
|
|
81
|
+
options.json = true;
|
|
82
|
+
} else if (arg === "--browser" || arg === "-b") {
|
|
83
|
+
options.browser = rawArgs[++i] || "";
|
|
84
|
+
} else if (arg === "--target") {
|
|
85
|
+
options.target = rawArgs[++i] || "";
|
|
86
|
+
} else if (arg === "--socket") {
|
|
87
|
+
options.socket = rawArgs[++i] || "";
|
|
88
|
+
} else if (arg === "--connect-timeout") {
|
|
89
|
+
const value = Number(rawArgs[++i]);
|
|
90
|
+
if (!Number.isFinite(value) || value < 0) throw new Error("--connect-timeout must be a non-negative number");
|
|
91
|
+
options.connectTimeoutMs = value;
|
|
92
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
93
|
+
options.help = true;
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`Unknown doctor option: ${arg}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!options.browser) throw new Error("--browser requires a value");
|
|
100
|
+
if (!options.target) throw new Error("--target requires a value");
|
|
101
|
+
if (!["auto", "linux", "windows"].includes(options.target)) {
|
|
102
|
+
throw new Error("--target must be auto, linux, or windows");
|
|
103
|
+
}
|
|
104
|
+
if (options.socket === "") throw new Error("--socket requires a value");
|
|
105
|
+
|
|
106
|
+
return options;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveBrowsers(browserArg) {
|
|
110
|
+
if (browserArg === "all") return Object.keys(BROWSERS);
|
|
111
|
+
const browsers = browserArg.split(",").map((browser) => browser.trim().toLowerCase()).filter(Boolean);
|
|
112
|
+
if (browsers.length === 0) throw new Error("--browser requires a browser name or all");
|
|
113
|
+
const unknown = browsers.filter((browser) => !BROWSERS[browser]);
|
|
114
|
+
if (unknown.length > 0) throw new Error(`Unknown browser: ${unknown.join(", ")}`);
|
|
115
|
+
return browsers;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getWindowsEnv(name, { env = process.env, execFileSync: execFile = execFileSync } = {}) {
|
|
119
|
+
if (env[name]) return env[name];
|
|
120
|
+
try {
|
|
121
|
+
return execFile("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
|
|
122
|
+
.trim()
|
|
123
|
+
.replace(/\r/g, "");
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function windowsPathToWslPath(winPath) {
|
|
130
|
+
const normalized = winPath.replace(/\\/g, "/");
|
|
131
|
+
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
132
|
+
if (!match) return normalized;
|
|
133
|
+
return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function manifestPathForBrowser(browserKey, context) {
|
|
137
|
+
const browser = BROWSERS[browserKey];
|
|
138
|
+
if (!browser) return null;
|
|
139
|
+
|
|
140
|
+
if (context.effectiveTarget === "wsl-windows") {
|
|
141
|
+
const localAppData = getWindowsEnv("LOCALAPPDATA", context);
|
|
142
|
+
if (!localAppData || !browser.wsl) return null;
|
|
143
|
+
return path.join(windowsPathToWslPath(localAppData), browser.wsl, `${HOST_NAME}.json`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (context.platform === "win32") {
|
|
147
|
+
if (!browser.win32) return null;
|
|
148
|
+
const localAppData = context.env.LOCALAPPDATA || path.join(context.homeDir, "AppData/Local");
|
|
149
|
+
return path.join(localAppData, "surf-cli", `${HOST_NAME}.json`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const manifestDir = browser[context.platform];
|
|
153
|
+
if (!manifestDir) return null;
|
|
154
|
+
return path.join(context.homeDir, manifestDir, `${HOST_NAME}.json`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fsPathFromManifestPath(manifestPath, context) {
|
|
158
|
+
if (context.platform === "linux" && /^[A-Za-z]:[\\/]/.test(manifestPath)) {
|
|
159
|
+
return windowsPathToWslPath(manifestPath);
|
|
160
|
+
}
|
|
161
|
+
return manifestPath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function windowsRegistryPathForBrowser(browserKey) {
|
|
165
|
+
const browser = BROWSERS[browserKey];
|
|
166
|
+
if (!browser?.win32) return null;
|
|
167
|
+
return `HKCU\\Software\\${browser.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readWindowsRegistryManifestPath(registryPath, context) {
|
|
171
|
+
try {
|
|
172
|
+
const output = context.execFileSync("reg", ["query", registryPath, "/ve"], { encoding: "utf8" });
|
|
173
|
+
const line = output.split(/\r?\n/).find((item) => item.includes("REG_SZ"));
|
|
174
|
+
if (!line) return null;
|
|
175
|
+
return line.replace(/^.*REG_SZ\s+/, "").trim() || null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkWindowsRegistry(browserKey, context) {
|
|
182
|
+
const registryPath = windowsRegistryPathForBrowser(browserKey);
|
|
183
|
+
if (!registryPath) {
|
|
184
|
+
return {
|
|
185
|
+
check: {
|
|
186
|
+
id: "windows-registry-supported",
|
|
187
|
+
status: "fail",
|
|
188
|
+
browser: browserKey,
|
|
189
|
+
message: `${BROWSERS[browserKey].name} native messaging registry is not supported on Windows`,
|
|
190
|
+
},
|
|
191
|
+
manifestPath: null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const manifestPath = readWindowsRegistryManifestPath(registryPath, context);
|
|
196
|
+
return {
|
|
197
|
+
check: {
|
|
198
|
+
id: "windows-registry",
|
|
199
|
+
status: manifestPath ? "pass" : "fail",
|
|
200
|
+
browser: browserKey,
|
|
201
|
+
message: manifestPath
|
|
202
|
+
? `Windows native messaging registry points to ${manifestPath}`
|
|
203
|
+
: `Windows native messaging registry entry not found: ${registryPath}`,
|
|
204
|
+
registryPath,
|
|
205
|
+
path: manifestPath,
|
|
206
|
+
},
|
|
207
|
+
manifestPath,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkManifest(manifestPath, context) {
|
|
212
|
+
const checks = [];
|
|
213
|
+
const exists = manifestPath ? context.fs.existsSync(manifestPath) : false;
|
|
214
|
+
checks.push({
|
|
215
|
+
id: "manifest-file",
|
|
216
|
+
status: exists ? "pass" : "fail",
|
|
217
|
+
message: exists ? `Manifest found: ${manifestPath}` : "Native messaging manifest not found",
|
|
218
|
+
path: manifestPath,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!exists) return { checks, manifest: null };
|
|
222
|
+
|
|
223
|
+
let manifest;
|
|
224
|
+
try {
|
|
225
|
+
manifest = JSON.parse(context.fs.readFileSync(manifestPath, "utf8"));
|
|
226
|
+
checks.push({ id: "manifest-json", status: "pass", message: "Manifest JSON is valid" });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
checks.push({ id: "manifest-json", status: "fail", message: `Manifest JSON is invalid: ${error.message}` });
|
|
229
|
+
return { checks, manifest: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
233
|
+
checks.push({
|
|
234
|
+
id: "manifest-shape",
|
|
235
|
+
status: "fail",
|
|
236
|
+
message: "Manifest JSON must be an object",
|
|
237
|
+
});
|
|
238
|
+
return { checks, manifest: null };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
checks.push({
|
|
242
|
+
id: "manifest-name",
|
|
243
|
+
status: manifest.name === HOST_NAME ? "pass" : "fail",
|
|
244
|
+
message: manifest.name === HOST_NAME ? `Manifest name is ${HOST_NAME}` : `Manifest name is ${JSON.stringify(manifest.name)}; expected ${HOST_NAME}`,
|
|
245
|
+
});
|
|
246
|
+
checks.push({
|
|
247
|
+
id: "manifest-type",
|
|
248
|
+
status: manifest.type === "stdio" ? "pass" : "fail",
|
|
249
|
+
message: manifest.type === "stdio" ? "Manifest type is stdio" : `Manifest type is ${JSON.stringify(manifest.type)}; expected stdio`,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
|
|
253
|
+
const chromeOrigins = origins.filter((origin) => /^chrome-extension:\/\/[a-p]{32}\/$/.test(origin));
|
|
254
|
+
checks.push({
|
|
255
|
+
id: "manifest-origins",
|
|
256
|
+
status: chromeOrigins.length > 0 ? "pass" : "fail",
|
|
257
|
+
message: chromeOrigins.length > 0
|
|
258
|
+
? `Manifest has ${chromeOrigins.length} Chrome extension origin${chromeOrigins.length === 1 ? "" : "s"}`
|
|
259
|
+
: "Manifest has no valid chrome-extension://<extension-id>/ allowed_origins entry",
|
|
260
|
+
origins,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (typeof manifest.path === "string" && manifest.path.length > 0) {
|
|
264
|
+
const manifestFsPath = fsPathFromManifestPath(manifest.path, context);
|
|
265
|
+
const wrapperExists = context.fs.existsSync(manifestFsPath);
|
|
266
|
+
checks.push({
|
|
267
|
+
id: "manifest-path",
|
|
268
|
+
status: wrapperExists ? "pass" : "fail",
|
|
269
|
+
message: wrapperExists ? `Manifest path exists: ${manifest.path}` : `Manifest path does not exist: ${manifest.path}`,
|
|
270
|
+
path: manifest.path,
|
|
271
|
+
fsPath: manifestFsPath,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (wrapperExists && context.platform !== "win32" && !manifest.path.endsWith(".cmd") && !manifest.path.endsWith(".bat")) {
|
|
275
|
+
try {
|
|
276
|
+
const mode = context.fs.statSync(manifestFsPath).mode;
|
|
277
|
+
checks.push({
|
|
278
|
+
id: "manifest-path-executable",
|
|
279
|
+
status: mode & 0o111 ? "pass" : "fail",
|
|
280
|
+
message: mode & 0o111 ? "Manifest wrapper is executable" : "Manifest wrapper exists but is not executable",
|
|
281
|
+
});
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
checks.push({ id: "manifest-path", status: "fail", message: "Manifest path is missing" });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { checks, manifest };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function checkSocket(socketPath, context) {
|
|
292
|
+
const checks = [];
|
|
293
|
+
if (context.platform !== "win32") {
|
|
294
|
+
const exists = context.fs.existsSync(socketPath);
|
|
295
|
+
checks.push({
|
|
296
|
+
id: "socket-file",
|
|
297
|
+
status: exists ? "pass" : "fail",
|
|
298
|
+
message: exists ? `Socket path exists: ${socketPath}` : `Socket path does not exist: ${socketPath}`,
|
|
299
|
+
path: socketPath,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (exists) {
|
|
303
|
+
try {
|
|
304
|
+
const stat = context.fs.statSync(socketPath);
|
|
305
|
+
checks.push({
|
|
306
|
+
id: "socket-type",
|
|
307
|
+
status: stat.isSocket() ? "pass" : "warn",
|
|
308
|
+
message: stat.isSocket() ? "Socket path is a Unix socket" : "Socket path exists but is not a Unix socket",
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
checks.push({ id: "socket-type", status: "warn", message: `Could not stat socket path: ${error.message}` });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const connection = await context.connectSocket(socketPath, context.connectTimeoutMs);
|
|
317
|
+
checks.push({
|
|
318
|
+
id: "socket-connect",
|
|
319
|
+
status: connection.ok ? "pass" : "fail",
|
|
320
|
+
message: connection.ok ? "Connected to native host socket" : `Could not connect to socket: ${connection.message}`,
|
|
321
|
+
code: connection.code,
|
|
322
|
+
});
|
|
323
|
+
return checks;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function connectSocket(socketPath, timeoutMs) {
|
|
327
|
+
return new Promise((resolve) => {
|
|
328
|
+
let settled = false;
|
|
329
|
+
const socket = net.createConnection(socketPath);
|
|
330
|
+
const finish = (result) => {
|
|
331
|
+
if (settled) return;
|
|
332
|
+
settled = true;
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
socket.destroy();
|
|
335
|
+
resolve(result);
|
|
336
|
+
};
|
|
337
|
+
const timeout = setTimeout(() => {
|
|
338
|
+
finish({ ok: false, code: "ETIMEDOUT", message: `timed out after ${timeoutMs}ms` });
|
|
339
|
+
}, timeoutMs);
|
|
340
|
+
socket.once("connect", () => finish({ ok: true, message: "connected" }));
|
|
341
|
+
socket.once("error", (error) => finish({
|
|
342
|
+
ok: false,
|
|
343
|
+
code: error.code,
|
|
344
|
+
message: error && error.message ? error.message : String(error),
|
|
345
|
+
}));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resolveEffectiveTarget(options, context) {
|
|
350
|
+
if (options.target === "linux" && context.platform !== "linux") {
|
|
351
|
+
throw new Error("--target linux is only supported on Linux or WSL2");
|
|
352
|
+
}
|
|
353
|
+
if (options.target === "windows" && !context.runningInWsl && context.platform !== "win32") {
|
|
354
|
+
throw new Error("--target windows is only supported on Windows or WSL2");
|
|
355
|
+
}
|
|
356
|
+
return context.runningInWsl && options.target !== "linux" ? "wsl-windows" : context.platform;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function statusRank(status) {
|
|
360
|
+
return { fail: 3, warn: 2, pass: 1, info: 0 }[status] || 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function summarize(checks) {
|
|
364
|
+
return {
|
|
365
|
+
pass: checks.filter((check) => check.status === "pass").length,
|
|
366
|
+
warn: checks.filter((check) => check.status === "warn").length,
|
|
367
|
+
fail: checks.filter((check) => check.status === "fail").length,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function buildRecommendations(report) {
|
|
372
|
+
const recommendations = [];
|
|
373
|
+
const failedIds = new Set(report.checks.filter((check) => check.status === "fail").map((check) => check.id));
|
|
374
|
+
|
|
375
|
+
if (failedIds.has("windows-registry")) {
|
|
376
|
+
recommendations.push("Run `surf install <extension-id> --browser <browser>` so Windows registers the native messaging host, then restart the browser.");
|
|
377
|
+
}
|
|
378
|
+
if (failedIds.has("manifest-file") || failedIds.has("manifest-shape")) {
|
|
379
|
+
recommendations.push("Run `surf install <extension-id>` with the extension ID from chrome://extensions, then restart the browser.");
|
|
380
|
+
}
|
|
381
|
+
if (failedIds.has("manifest-origins")) {
|
|
382
|
+
recommendations.push("Rerun `surf install <extension-id>` after copying the current Surf extension ID from chrome://extensions.");
|
|
383
|
+
}
|
|
384
|
+
if (failedIds.has("manifest-path") || failedIds.has("manifest-path-executable")) {
|
|
385
|
+
recommendations.push("Reinstall the native host so the manifest path points at the current Surf wrapper.");
|
|
386
|
+
}
|
|
387
|
+
if (failedIds.has("manifest-supported")) {
|
|
388
|
+
recommendations.push("Choose a browser supported for this target, or rerun with `--browser all` to inspect every supported setup.");
|
|
389
|
+
}
|
|
390
|
+
if (failedIds.has("socket-file") || failedIds.has("socket-connect")) {
|
|
391
|
+
recommendations.push("Make sure the browser is running with the Surf extension enabled, then restart the browser after install changes.");
|
|
392
|
+
}
|
|
393
|
+
if (report.environment.surfSocketSet) {
|
|
394
|
+
recommendations.push("SURF_SOCKET is set; make sure Chrome launches the native host with the same socket value.");
|
|
395
|
+
}
|
|
396
|
+
if (report.environment.runningInWsl && report.environment.effectiveTarget === "wsl-windows") {
|
|
397
|
+
recommendations.push("For WSL2 with Windows Chrome, run `surf install <extension-id>` from the same WSL distro and restart Windows Chrome. Use `--target linux` only for a Linux browser in WSLg.");
|
|
398
|
+
}
|
|
399
|
+
if (report.environment.platform === "darwin") {
|
|
400
|
+
recommendations.push("On macOS, confirm the extension ID in the manifest matches chrome://extensions and reopen the extension service worker console for native messaging errors.");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return Array.from(new Set(recommendations));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function remoteRecommendations(endpoint, code) {
|
|
407
|
+
const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
|
|
408
|
+
if (code === "ENOTFOUND") return [...base, "Check the Tailnet DNS name, then run `tailscale status` and `tailscale ping <host>`."].map((item) => item.replace("<host>", endpoint.host));
|
|
409
|
+
if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
|
|
410
|
+
if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
|
|
411
|
+
if (code === "ENETUNREACH" || code === "EHOSTUNREACH") return [...base, `Run \`tailscale status\` and \`tailscale ping ${endpoint.host}\`; check Tailnet routing plus restrictive ACLs/grants.`];
|
|
412
|
+
if (code === "EAUTH") return [...base, "Verify the client credential path, pinned host identity, and that the labeled client has not been revoked."].map((item) => item.replace("<host>", endpoint.host));
|
|
413
|
+
return base;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runDoctor(rawOptions = {}, deps = {}) {
|
|
417
|
+
const env = deps.env || process.env;
|
|
418
|
+
const platform = deps.platform || process.platform;
|
|
419
|
+
const homeDir = deps.homeDir || os.homedir();
|
|
420
|
+
const runningInWsl = isWsl({ platform, env, readFileSync: deps.readFileSync || fs.readFileSync });
|
|
421
|
+
const options = {
|
|
422
|
+
browser: rawOptions.browser || "chrome",
|
|
423
|
+
target: rawOptions.target || "auto",
|
|
424
|
+
socket: rawOptions.socket || env.SURF_SOCKET || defaultSocketPath(platform),
|
|
425
|
+
connectTimeoutMs: rawOptions.connectTimeoutMs ?? 750,
|
|
426
|
+
};
|
|
427
|
+
const selectedEndpoint = rawOptions.endpoint || selectEndpoint([], env).endpoint;
|
|
428
|
+
if (selectedEndpoint.kind === "remote") {
|
|
429
|
+
const endpoint = selectedEndpoint;
|
|
430
|
+
const connection = await (deps.connectEndpoint || ((target, timeoutMs) => new Promise((resolve) => {
|
|
431
|
+
let settled = false;
|
|
432
|
+
let socket;
|
|
433
|
+
const finish = (result) => {
|
|
434
|
+
if (settled) return;
|
|
435
|
+
settled = true;
|
|
436
|
+
clearTimeout(timeout);
|
|
437
|
+
socket?.destroy();
|
|
438
|
+
resolve(result);
|
|
439
|
+
};
|
|
440
|
+
const timeout = setTimeout(() => finish({
|
|
441
|
+
ok: false,
|
|
442
|
+
code: socket?.connected ? "EAUTH" : "ETIMEDOUT",
|
|
443
|
+
message: socket?.connected ? "remote authentication timed out" : `timed out after ${timeoutMs}ms`,
|
|
444
|
+
}), timeoutMs);
|
|
445
|
+
socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
|
|
446
|
+
socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
|
|
447
|
+
})))(endpoint, options.connectTimeoutMs);
|
|
448
|
+
const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}`, endpoint: endpoint.display }, {
|
|
449
|
+
id: "remote-connect", status: connection.ok ? "pass" : "fail",
|
|
450
|
+
message: connection.ok ? `Connected to remote endpoint ${endpoint.display}` : `Could not connect to remote endpoint ${endpoint.display}: ${connection.message}`,
|
|
451
|
+
code: connection.code,
|
|
452
|
+
}, {
|
|
453
|
+
id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
|
|
454
|
+
message: connection.ok ? "Remote credential authenticated and server identity verified" : connection.code === "EAUTH" ? "Remote credential rejected, revoked, or server identity mismatch" : "Remote authentication was not reached",
|
|
455
|
+
code: connection.code,
|
|
456
|
+
}];
|
|
457
|
+
const summary = summarize(checks);
|
|
458
|
+
const report = { ok: connection.ok, summary, environment: { platform, remoteEndpoint: endpoint.display, endpointKind: "remote", browsers: [] }, manifests: [], checks };
|
|
459
|
+
report.recommendations = connection.ok ? [] : remoteRecommendations(endpoint, connection.code);
|
|
460
|
+
return report;
|
|
461
|
+
}
|
|
462
|
+
const effectiveTarget = resolveEffectiveTarget(options, { platform, runningInWsl });
|
|
463
|
+
const browsers = resolveBrowsers(options.browser);
|
|
464
|
+
const context = {
|
|
465
|
+
platform,
|
|
466
|
+
homeDir,
|
|
467
|
+
env,
|
|
468
|
+
runningInWsl,
|
|
469
|
+
effectiveTarget,
|
|
470
|
+
fs: deps.fs || fs,
|
|
471
|
+
execFileSync: deps.execFileSync || execFileSync,
|
|
472
|
+
connectSocket: deps.connectSocket || connectSocket,
|
|
473
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const checks = [];
|
|
477
|
+
checks.push({ id: "platform", status: "info", message: `Platform: ${platform}${runningInWsl ? " (WSL2 detected)" : ""}` });
|
|
478
|
+
checks.push({ id: "target", status: "info", message: `Install target: ${effectiveTarget === "wsl-windows" ? "Windows browser from WSL2" : effectiveTarget}` });
|
|
479
|
+
checks.push({ id: "socket-path", status: "info", message: `Socket path: ${options.socket}`, path: options.socket });
|
|
480
|
+
|
|
481
|
+
checks.push(...await checkSocket(options.socket, context));
|
|
482
|
+
|
|
483
|
+
const manifests = [];
|
|
484
|
+
for (const browserKey of browsers) {
|
|
485
|
+
const browser = BROWSERS[browserKey];
|
|
486
|
+
const browserChecks = [];
|
|
487
|
+
let manifestPath = manifestPathForBrowser(browserKey, context);
|
|
488
|
+
|
|
489
|
+
if (context.platform === "win32" && browser.win32) {
|
|
490
|
+
const registry = checkWindowsRegistry(browserKey, context);
|
|
491
|
+
browserChecks.push(registry.check);
|
|
492
|
+
if (registry.manifestPath) manifestPath = registry.manifestPath;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!manifestPath) {
|
|
496
|
+
const check = {
|
|
497
|
+
id: "manifest-supported",
|
|
498
|
+
status: options.browser === "all" ? "warn" : "fail",
|
|
499
|
+
browser: browserKey,
|
|
500
|
+
message: `${browser.name} native messaging is not supported for ${effectiveTarget}`,
|
|
501
|
+
};
|
|
502
|
+
browserChecks.push(check);
|
|
503
|
+
checks.push(...browserChecks);
|
|
504
|
+
manifests.push({ browser: browserKey, name: browser.name, path: null, supported: false, checks: browserChecks });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const result = checkManifest(manifestPath, context);
|
|
509
|
+
browserChecks.push(...result.checks.map((check) => ({ ...check, browser: browserKey })));
|
|
510
|
+
checks.push(...browserChecks);
|
|
511
|
+
manifests.push({
|
|
512
|
+
browser: browserKey,
|
|
513
|
+
name: browser.name,
|
|
514
|
+
path: manifestPath,
|
|
515
|
+
supported: true,
|
|
516
|
+
manifest: result.manifest,
|
|
517
|
+
checks: browserChecks,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
checks.sort((a, b) => statusRank(b.status) - statusRank(a.status));
|
|
522
|
+
const summary = summarize(checks);
|
|
523
|
+
const report = {
|
|
524
|
+
ok: summary.fail === 0,
|
|
525
|
+
summary,
|
|
526
|
+
environment: {
|
|
527
|
+
platform,
|
|
528
|
+
runningInWsl,
|
|
529
|
+
effectiveTarget,
|
|
530
|
+
socketPath: options.socket,
|
|
531
|
+
surfSocketSet: Boolean(env.SURF_SOCKET),
|
|
532
|
+
socketOverrideSet: Boolean(rawOptions.socket),
|
|
533
|
+
browsers,
|
|
534
|
+
},
|
|
535
|
+
manifests,
|
|
536
|
+
checks,
|
|
537
|
+
};
|
|
538
|
+
report.recommendations = buildRecommendations(report);
|
|
539
|
+
return report;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function formatCheck(check) {
|
|
543
|
+
const labels = { pass: "PASS", warn: "WARN", fail: "FAIL", info: "INFO" };
|
|
544
|
+
const browser = check.browser ? ` [${check.browser}]` : "";
|
|
545
|
+
return `[${labels[check.status] || check.status.toUpperCase()}]${browser} ${check.message}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function formatDoctorReport(report) {
|
|
549
|
+
const lines = ["Surf doctor", ""];
|
|
550
|
+
lines.push(`Platform: ${report.environment.platform}${report.environment.runningInWsl ? " (WSL2 detected)" : ""}`);
|
|
551
|
+
if (report.environment.endpointKind === "remote") {
|
|
552
|
+
lines.push(`Remote endpoint: ${report.environment.remoteEndpoint}`);
|
|
553
|
+
} else {
|
|
554
|
+
lines.push(`Target: ${report.environment.effectiveTarget === "wsl-windows" ? "Windows browser from WSL2" : report.environment.effectiveTarget}`);
|
|
555
|
+
}
|
|
556
|
+
if (report.environment.socketPath) lines.push(`Socket: ${report.environment.socketPath}`);
|
|
557
|
+
if (report.environment.browsers.length) lines.push(`Browsers: ${report.environment.browsers.join(", ")}`);
|
|
558
|
+
lines.push("");
|
|
559
|
+
|
|
560
|
+
for (const check of report.checks.filter((item) => item.status !== "info")) {
|
|
561
|
+
lines.push(formatCheck(check));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (report.recommendations.length > 0) {
|
|
565
|
+
lines.push("", "Next steps:");
|
|
566
|
+
for (const recommendation of report.recommendations) {
|
|
567
|
+
lines.push(`- ${recommendation}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
lines.push("", report.ok ? "Doctor result: OK" : "Doctor result: issues found");
|
|
572
|
+
return lines.join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function doctorHelp() {
|
|
576
|
+
return `Usage: surf doctor [options]
|
|
577
|
+
|
|
578
|
+
Diagnose native host and socket setup without requiring a working browser connection.
|
|
579
|
+
|
|
580
|
+
Options:
|
|
581
|
+
-b, --browser <name> Browser to inspect (default: chrome; supports chrome, chromium, brave, edge, arc, helium, all)
|
|
582
|
+
--target <target> Install target to inspect: auto, linux, windows (default: auto)
|
|
583
|
+
--socket <path> Socket path or named pipe to check (default: SURF_SOCKET or platform default)
|
|
584
|
+
--connect-timeout <ms> Socket connection timeout (default: 750)
|
|
585
|
+
--json Print machine-readable diagnostics
|
|
586
|
+
|
|
587
|
+
Examples:
|
|
588
|
+
surf doctor
|
|
589
|
+
surf doctor --browser brave
|
|
590
|
+
surf doctor --browser all --json
|
|
591
|
+
surf doctor --target linux
|
|
592
|
+
`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function runDoctorCli(rawArgs, endpoint) {
|
|
596
|
+
let options;
|
|
597
|
+
try {
|
|
598
|
+
options = parseDoctorArgs(rawArgs);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
console.error(`Error: ${error.message}`);
|
|
601
|
+
console.error("Run `surf doctor --help` for usage.");
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (options.help) {
|
|
606
|
+
console.log(doctorHelp());
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const report = await runDoctor({ ...options, endpoint });
|
|
612
|
+
if (options.json) {
|
|
613
|
+
console.log(JSON.stringify(report, null, 2));
|
|
614
|
+
} else {
|
|
615
|
+
console.log(formatDoctorReport(report));
|
|
616
|
+
}
|
|
617
|
+
return report.ok ? 0 : 1;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.error(`Error: ${error.message}`);
|
|
620
|
+
return 1;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
module.exports = {
|
|
625
|
+
BROWSERS,
|
|
626
|
+
HOST_NAME,
|
|
627
|
+
doctorHelp,
|
|
628
|
+
formatDoctorReport,
|
|
629
|
+
parseDoctorArgs,
|
|
630
|
+
runDoctor,
|
|
631
|
+
runDoctorCli,
|
|
632
|
+
windowsPathToWslPath,
|
|
633
|
+
};
|