UNPKG

mcp-chrome-bridge

Version:

Chrome Native-Messaging host (Node)

930 lines 38.1 kB
#!/usr/bin/env node "use strict"; /** * doctor.ts * * Diagnoses common installation and runtime issues for the Chrome Native Messaging host. * Provides checks for manifest files, Node.js path, permissions, and connectivity. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.collectDoctorReport = collectDoctorReport; exports.runDoctor = runDoctor; const fs_1 = __importDefault(require("fs")); const os_1 = __importDefault(require("os")); const path_1 = __importDefault(require("path")); const child_process_1 = require("child_process"); const constant_1 = require("./constant"); const browser_config_1 = require("./browser-config"); const utils_1 = require("./utils"); const constant_2 = require("../constant"); const EXPECTED_PORT = 12306; const SCHEMA_VERSION = 1; const MIN_NODE_MAJOR_VERSION = 20; // ============================================================================ // Utility Functions // ============================================================================ function readPackageJson() { try { return require('../../package.json'); } catch (_a) { return {}; } } function getCommandInfo(pkg) { const bin = pkg.bin; if (!bin || typeof bin !== 'object') { return { canonical: constant_1.COMMAND_NAME, aliases: [] }; } const canonical = constant_1.COMMAND_NAME; const canonicalTarget = bin[canonical]; const aliases = canonicalTarget ? Object.keys(bin).filter((name) => name !== canonical && bin[name] === canonicalTarget) : []; return { canonical, aliases }; } function resolveDistDir() { // __dirname is dist/scripts when running from compiled code const candidateFromDistScripts = path_1.default.resolve(__dirname, '..'); const candidateFromSrcScripts = path_1.default.resolve(__dirname, '..', '..', 'dist'); const looksLikeDist = (dir) => { return (fs_1.default.existsSync(path_1.default.join(dir, 'mcp', 'stdio-config.json')) || fs_1.default.existsSync(path_1.default.join(dir, 'run_host.sh')) || fs_1.default.existsSync(path_1.default.join(dir, 'run_host.bat'))); }; if (looksLikeDist(candidateFromDistScripts)) return candidateFromDistScripts; if (looksLikeDist(candidateFromSrcScripts)) return candidateFromSrcScripts; return candidateFromDistScripts; } function stringifyError(err) { if (err instanceof Error) return err.message; return String(err); } function canExecute(filePath) { try { fs_1.default.accessSync(filePath, fs_1.default.constants.X_OK); return true; } catch (_a) { return false; } } function normalizeComparablePath(filePath) { if (process.platform === 'win32') { return path_1.default.normalize(filePath).toLowerCase(); } return path_1.default.normalize(filePath); } function stripOuterQuotes(input) { const trimmed = input.trim(); if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return trimmed.slice(1, -1); } return trimmed; } function expandTilde(inputPath) { if (inputPath === '~') return os_1.default.homedir(); if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { return path_1.default.join(os_1.default.homedir(), inputPath.slice(2)); } return inputPath; } function expandWindowsEnvVars(input) { if (process.platform !== 'win32') return input; return input.replace(/%([^%]+)%/g, (_match, name) => { var _a, _b, _c; const key = String(name); return ((_c = (_b = (_a = process.env[key]) !== null && _a !== void 0 ? _a : process.env[key.toUpperCase()]) !== null && _b !== void 0 ? _b : process.env[key.toLowerCase()]) !== null && _c !== void 0 ? _c : _match); }); } function parseVersionFromDirName(dirName) { const cleaned = dirName.trim().replace(/^v/, ''); if (!/^\d+(\.\d+){0,3}$/.test(cleaned)) return null; return cleaned.split('.').map((part) => Number(part)); } /** * Parse Node.js version string from `node -v` output. * Handles versions like: v20.10.0, v22.0.0-nightly.2024..., v21.0.0-rc.1 * Returns major version number or null if parsing fails. */ function parseNodeMajorVersion(versionString) { if (!versionString) return null; // Match pattern: v?MAJOR.MINOR.PATCH[-anything] const match = versionString.trim().match(/^v?(\d+)(?:\.\d+)*(?:[-+].*)?$/i); if (match === null || match === void 0 ? void 0 : match[1]) { const major = Number(match[1]); return Number.isNaN(major) ? null : major; } return null; } function compareVersions(a, b) { var _a, _b; const len = Math.max(a.length, b.length); for (let i = 0; i < len; i++) { const av = (_a = a[i]) !== null && _a !== void 0 ? _a : 0; const bv = (_b = b[i]) !== null && _b !== void 0 ? _b : 0; if (av !== bv) return av - bv; } return 0; } function pickLatestVersionDir(parentDir) { if (!fs_1.default.existsSync(parentDir)) return null; const dirents = fs_1.default.readdirSync(parentDir, { withFileTypes: true }); let best = null; for (const dirent of dirents) { if (!dirent.isDirectory()) continue; const parsed = parseVersionFromDirName(dirent.name); if (!parsed) continue; if (!best || compareVersions(parsed, best.version) > 0) { best = { name: dirent.name, version: parsed }; } } return best ? path_1.default.join(parentDir, best.name) : null; } // ============================================================================ // Node Resolution (mirrors run_host.sh/bat logic) // ============================================================================ function resolveNodeCandidate(distDir) { const nodeFileName = process.platform === 'win32' ? 'node.exe' : 'node'; const nodePathFilePath = path_1.default.join(distDir, 'node_path.txt'); const nodePathFile = { path: nodePathFilePath, exists: fs_1.default.existsSync(nodePathFilePath), }; const consider = (source, rawCandidate) => { if (!rawCandidate) return null; let candidate = expandTilde(stripOuterQuotes(rawCandidate)); try { if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isDirectory()) { candidate = path_1.default.join(candidate, nodeFileName); } } catch (_a) { // ignore } if (canExecute(candidate)) { return { nodePath: candidate, source }; } return null; }; // Priority 0: CHROME_MCP_NODE_PATH const fromEnv = consider('CHROME_MCP_NODE_PATH', process.env.CHROME_MCP_NODE_PATH); if (fromEnv) { return { ...fromEnv, nodePathFile }; } // Priority 1: node_path.txt if (nodePathFile.exists) { try { const content = fs_1.default.readFileSync(nodePathFilePath, 'utf8').trim(); nodePathFile.value = content; const fromFile = consider('node_path.txt', content); nodePathFile.valid = Boolean(fromFile); if (fromFile) { return { ...fromFile, nodePathFile }; } } catch (e) { nodePathFile.error = stringifyError(e); nodePathFile.valid = false; } } // Priority 1.5: Relative path fallback (mirrors run_host.sh/bat) // Unix: ../../../bin/node (from dist/) // Windows: ..\..\..\node.exe (from dist/, no bin/ subdirectory) const relativeNodePath = process.platform === 'win32' ? path_1.default.resolve(distDir, '..', '..', '..', nodeFileName) : path_1.default.resolve(distDir, '..', '..', '..', 'bin', nodeFileName); const fromRelative = consider('relative', relativeNodePath); if (fromRelative) return { ...fromRelative, nodePathFile }; // Priority 2: Volta const voltaHome = process.env.VOLTA_HOME || path_1.default.join(os_1.default.homedir(), '.volta'); const fromVolta = consider('volta', path_1.default.join(voltaHome, 'bin', nodeFileName)); if (fromVolta) return { ...fromVolta, nodePathFile }; // Priority 3: asdf (cross-platform) const asdfDir = process.env.ASDF_DATA_DIR || path_1.default.join(os_1.default.homedir(), '.asdf'); const asdfNodejsDir = path_1.default.join(asdfDir, 'installs', 'nodejs'); const latestAsdf = pickLatestVersionDir(asdfNodejsDir); if (latestAsdf) { const fromAsdf = consider('asdf', path_1.default.join(latestAsdf, 'bin', nodeFileName)); if (fromAsdf) return { ...fromAsdf, nodePathFile }; } // Priority 4: fnm (cross-platform, Windows uses different layout) const fnmDir = process.env.FNM_DIR || path_1.default.join(os_1.default.homedir(), '.fnm'); const fnmVersionsDir = path_1.default.join(fnmDir, 'node-versions'); const latestFnm = pickLatestVersionDir(fnmVersionsDir); if (latestFnm) { const fnmNodePath = process.platform === 'win32' ? path_1.default.join(latestFnm, 'installation', nodeFileName) : path_1.default.join(latestFnm, 'installation', 'bin', nodeFileName); const fromFnm = consider('fnm', fnmNodePath); if (fromFnm) return { ...fromFnm, nodePathFile }; } // Priority 5: NVM (Unix only) if (process.platform !== 'win32') { const nvmDir = process.env.NVM_DIR || path_1.default.join(os_1.default.homedir(), '.nvm'); const nvmDefaultAlias = path_1.default.join(nvmDir, 'alias', 'default'); try { if (fs_1.default.existsSync(nvmDefaultAlias)) { const stat = fs_1.default.lstatSync(nvmDefaultAlias); const maybeVersion = stat.isSymbolicLink() ? fs_1.default.readlinkSync(nvmDefaultAlias).trim() : fs_1.default.readFileSync(nvmDefaultAlias, 'utf8').trim(); const fromDefault = consider('nvm-default', path_1.default.join(nvmDir, 'versions', 'node', maybeVersion, 'bin', 'node')); if (fromDefault) return { ...fromDefault, nodePathFile }; } } catch (_a) { // ignore } const latestNvm = pickLatestVersionDir(path_1.default.join(nvmDir, 'versions', 'node')); if (latestNvm) { const fromNvm = consider('nvm-latest', path_1.default.join(latestNvm, 'bin', 'node')); if (fromNvm) return { ...fromNvm, nodePathFile }; } } // Priority 6: Common paths const commonPaths = process.platform === 'win32' ? [ path_1.default.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'node.exe'), path_1.default.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'), path_1.default.join(process.env.LOCALAPPDATA || '', 'Programs', 'nodejs', 'node.exe'), ].filter((p) => path_1.default.isAbsolute(p)) : ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']; for (const common of commonPaths) { const resolved = consider('common', common); if (resolved) return { ...resolved, nodePathFile }; } // Priority 7: PATH const pathEnv = process.env.PATH || ''; for (const rawDir of pathEnv.split(path_1.default.delimiter)) { const dir = stripOuterQuotes(rawDir); if (!dir) continue; const candidate = path_1.default.join(dir, nodeFileName); if (canExecute(candidate)) { return { nodePath: candidate, source: 'PATH', nodePathFile }; } } return { nodePathFile }; } // ============================================================================ // Browser Resolution // ============================================================================ function resolveTargetBrowsers(browserArg) { if (!browserArg) return undefined; const normalized = browserArg.toLowerCase(); if (normalized === 'all') return [browser_config_1.BrowserType.CHROME, browser_config_1.BrowserType.CHROMIUM]; if (normalized === 'detect' || normalized === 'auto') return undefined; const parsed = (0, browser_config_1.parseBrowserType)(normalized); if (!parsed) { throw new Error(`Invalid browser: ${browserArg}. Use 'chrome', 'chromium', or 'all'`); } return [parsed]; } function resolveBrowsersToCheck(requested) { if (requested && requested.length > 0) return requested; const detected = (0, browser_config_1.detectInstalledBrowsers)(); if (detected.length > 0) return detected; return [browser_config_1.BrowserType.CHROME, browser_config_1.BrowserType.CHROMIUM]; } function queryWindowsRegistryDefaultValue(registryKey) { try { const output = (0, child_process_1.execFileSync)('reg', ['query', registryKey, '/ve'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, }); const lines = output .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); for (const line of lines) { const match = line.match(/\b(REG_SZ|REG_EXPAND_SZ)\b\s+(.*)$/i); if (match === null || match === void 0 ? void 0 : match[2]) { const valueType = match[1].toUpperCase(); return { value: match[2].trim(), valueType }; } } return { error: 'No REG_SZ/REG_EXPAND_SZ default value found' }; } catch (e) { return { error: stringifyError(e) }; } } // ============================================================================ // Fix Attempts // ============================================================================ async function attemptFixes(enabled, silent, distDir, targetBrowsers) { if (!enabled) return []; const fixes = []; const logDir = (0, utils_1.getLogDir)(); const nodePathFile = path_1.default.join(distDir, 'node_path.txt'); const withMutedConsole = async (fn) => { if (!silent) return await fn(); const originalLog = console.log; const originalInfo = console.info; const originalWarn = console.warn; const originalError = console.error; console.log = () => { }; console.info = () => { }; console.warn = () => { }; console.error = () => { }; try { return await fn(); } finally { console.log = originalLog; console.info = originalInfo; console.warn = originalWarn; console.error = originalError; } }; const attempt = async (id, description, action) => { try { await withMutedConsole(async () => { await action(); }); fixes.push({ id, description, success: true }); } catch (e) { fixes.push({ id, description, success: false, error: stringifyError(e) }); } }; await attempt('logs', 'Ensure logs directory exists', async () => { fs_1.default.mkdirSync(logDir, { recursive: true }); }); await attempt('node_path', 'Write node_path.txt for run_host scripts', async () => { fs_1.default.writeFileSync(nodePathFile, process.execPath, 'utf8'); }); await attempt('permissions', 'Fix execution permissions for native host files', async () => { await (0, utils_1.ensureExecutionPermissions)(); }); await attempt('register', 'Re-register Native Messaging host (user-level)', async () => { const ok = await (0, utils_1.tryRegisterUserLevelHost)(targetBrowsers); if (!ok) { throw new Error('User-level registration failed'); } }); return fixes; } // ============================================================================ // JSON File Reading // ============================================================================ function readJsonFile(filePath) { try { const raw = fs_1.default.readFileSync(filePath, 'utf8'); return { ok: true, value: JSON.parse(raw) }; } catch (e) { return { ok: false, error: stringifyError(e) }; } } function resolveFetch() { var _a; if (typeof globalThis.fetch === 'function') { return globalThis.fetch.bind(globalThis); } try { const mod = require('node-fetch'); return ((_a = mod.default) !== null && _a !== void 0 ? _a : mod); } catch (_b) { return null; } } async function checkConnectivity(url, timeoutMs) { const fetchFn = resolveFetch(); if (!fetchFn) { return { ok: false, error: 'fetch is not available (requires Node.js >=18 or node-fetch)' }; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); // Prevent timeout from keeping the process alive if (typeof timeout.unref === 'function') { timeout.unref(); } try { const res = await fetchFn(url, { method: 'GET', signal: controller.signal }); return { ok: res.ok, status: res.status }; } catch (e) { const errMessage = e instanceof Error ? e.message : String(e); const errName = e instanceof Error ? e.name : ''; if (errName === 'AbortError' || errMessage.toLowerCase().includes('abort')) { return { ok: false, error: `Timeout after ${timeoutMs}ms` }; } return { ok: false, error: errMessage }; } finally { clearTimeout(timeout); } } // ============================================================================ // Summary Computation // ============================================================================ function computeSummary(checks) { let ok = 0; let warn = 0; let error = 0; for (const check of checks) { if (check.status === 'ok') ok++; else if (check.status === 'warn') warn++; else error++; } return { ok, warn, error }; } function statusBadge(status) { if (status === 'ok') return (0, utils_1.colorText)('[OK]', 'green'); if (status === 'warn') return (0, utils_1.colorText)('[WARN]', 'yellow'); return (0, utils_1.colorText)('[ERROR]', 'red'); } // ============================================================================ // Main Doctor Function // ============================================================================ /** * Collect doctor report without outputting to console. * Used by both runDoctor and report command. */ async function collectDoctorReport(options) { const pkg = readPackageJson(); const distDir = resolveDistDir(); const rootDir = path_1.default.resolve(distDir, '..'); const packageName = typeof pkg.name === 'string' ? pkg.name : 'mcp-chrome-bridge'; const packageVersion = typeof pkg.version === 'string' ? pkg.version : 'unknown'; const commandInfo = getCommandInfo(pkg); const targetBrowsers = resolveTargetBrowsers(options.browser); const browsersToCheck = resolveBrowsersToCheck(targetBrowsers); const wrapperScriptName = process.platform === 'win32' ? 'run_host.bat' : 'run_host.sh'; const wrapperPath = path_1.default.resolve(distDir, wrapperScriptName); const nodeScriptPath = path_1.default.resolve(distDir, 'index.js'); const logDir = (0, utils_1.getLogDir)(); const stdioConfigPath = path_1.default.resolve(distDir, 'mcp', 'stdio-config.json'); // Run fixes if requested const fixes = await attemptFixes(Boolean(options.fix), Boolean(options.json), distDir, targetBrowsers); const checks = []; const nextSteps = []; // Check 1: Installation info checks.push({ id: 'installation', title: 'Installation', status: 'ok', message: `${packageName}@${packageVersion}, ${process.platform}-${process.arch}, node ${process.version}`, details: { packageRoot: rootDir, distDir, execPath: process.execPath, aliases: commandInfo.aliases, }, }); // Check 2: Host files const missingHostFiles = []; if (!fs_1.default.existsSync(wrapperPath)) missingHostFiles.push(wrapperPath); if (!fs_1.default.existsSync(nodeScriptPath)) missingHostFiles.push(nodeScriptPath); if (!fs_1.default.existsSync(stdioConfigPath)) missingHostFiles.push(stdioConfigPath); if (missingHostFiles.length > 0) { checks.push({ id: 'host.files', title: 'Host files', status: 'error', message: `Missing required files (${missingHostFiles.length})`, details: { missing: missingHostFiles }, }); nextSteps.push(`Reinstall: npm install -g ${constant_1.COMMAND_NAME}`); } else { checks.push({ id: 'host.files', title: 'Host files', status: 'ok', message: `Wrapper: ${wrapperPath}`, details: { wrapperPath, nodeScriptPath, stdioConfigPath }, }); } // Check 3: Permissions (Unix only) if (process.platform !== 'win32' && fs_1.default.existsSync(wrapperPath)) { const executable = canExecute(wrapperPath); checks.push({ id: 'host.permissions', title: 'Host permissions', status: executable ? 'ok' : 'error', message: executable ? 'run_host.sh is executable' : 'run_host.sh is not executable', details: { path: wrapperPath, fix: executable ? undefined : [`${constant_1.COMMAND_NAME} fix-permissions`, `chmod +x "${wrapperPath}"`], }, }); if (!executable) nextSteps.push(`${constant_1.COMMAND_NAME} fix-permissions`); } else { checks.push({ id: 'host.permissions', title: 'Host permissions', status: 'ok', message: process.platform === 'win32' ? 'Not applicable on Windows' : 'N/A', }); } // Check 4: Node resolution const nodeResolution = resolveNodeCandidate(distDir); if (nodeResolution.nodePath) { try { nodeResolution.version = (0, child_process_1.execFileSync)(nodeResolution.nodePath, ['-v'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, }).trim(); } catch (e) { nodeResolution.versionError = stringifyError(e); } } // Parse Node version and check if it meets minimum requirement const nodeMajorVersion = parseNodeMajorVersion(nodeResolution.version || ''); const nodeVersionTooOld = nodeMajorVersion !== null && nodeMajorVersion < MIN_NODE_MAJOR_VERSION; const nodePathWarn = Boolean(nodeResolution.nodePath) && (!nodeResolution.nodePathFile.exists || nodeResolution.nodePathFile.valid === false) && !process.env.CHROME_MCP_NODE_PATH; // Determine node check status: error if not found or version too old, warn if path issue let nodeStatus = 'ok'; let nodeMessage; let nodeFix; if (!nodeResolution.nodePath) { nodeStatus = 'error'; nodeMessage = 'Node.js executable not found by wrapper search order'; nodeFix = [ `${constant_1.COMMAND_NAME} doctor --fix`, `Or set CHROME_MCP_NODE_PATH to an absolute node path`, ]; nextSteps.push(`${constant_1.COMMAND_NAME} doctor --fix`); } else if (nodeResolution.versionError) { nodeStatus = 'error'; nodeMessage = `Found ${nodeResolution.source}: ${nodeResolution.nodePath} but failed to run "node -v" (${nodeResolution.versionError})`; nodeFix = [ `Verify the executable: "${nodeResolution.nodePath}" -v`, `Reinstall/repair Node.js`, ]; nextSteps.push(`Verify Node.js: "${nodeResolution.nodePath}" -v`); } else if (nodeVersionTooOld) { nodeStatus = 'error'; nodeMessage = `Node.js ${nodeResolution.version} is too old (requires >= ${MIN_NODE_MAJOR_VERSION}.0.0)`; nodeFix = [`Upgrade Node.js to version ${MIN_NODE_MAJOR_VERSION} or higher`]; nextSteps.push(`Upgrade Node.js to version ${MIN_NODE_MAJOR_VERSION}+`); } else if (nodePathWarn) { nodeStatus = 'warn'; nodeMessage = `Using ${nodeResolution.source}: ${nodeResolution.nodePath}${nodeResolution.version ? ` (${nodeResolution.version})` : ''}`; nodeFix = [ `${constant_1.COMMAND_NAME} doctor --fix`, `Or set CHROME_MCP_NODE_PATH to an absolute node path`, ]; } else { nodeStatus = 'ok'; nodeMessage = `Using ${nodeResolution.source}: ${nodeResolution.nodePath}${nodeResolution.version ? ` (${nodeResolution.version})` : ''}`; } checks.push({ id: 'node', title: 'Node executable', status: nodeStatus, message: nodeMessage, details: { resolved: nodeResolution.nodePath ? { source: nodeResolution.source, path: nodeResolution.nodePath, version: nodeResolution.version, versionError: nodeResolution.versionError, majorVersion: nodeMajorVersion, } : undefined, nodePathFile: nodeResolution.nodePathFile, minRequired: `>=${MIN_NODE_MAJOR_VERSION}.0.0`, fix: nodeFix, }, }); // Check 5: Manifest checks per browser const expectedOrigin = `chrome-extension://${constant_1.EXTENSION_ID}/`; for (const browser of browsersToCheck) { const config = (0, browser_config_1.getBrowserConfig)(browser); const candidates = [config.userManifestPath, config.systemManifestPath]; const found = candidates.find((p) => fs_1.default.existsSync(p)); if (!found) { checks.push({ id: `manifest.${browser}`, title: `${config.displayName} manifest`, status: 'error', message: 'Manifest not found', details: { expected: candidates, fix: [ `${constant_1.COMMAND_NAME} register --browser ${browser}`, `${constant_1.COMMAND_NAME} register --detect`, ], }, }); nextSteps.push(`${constant_1.COMMAND_NAME} register --detect`); continue; } const parsed = readJsonFile(found); if (!parsed.ok) { checks.push({ id: `manifest.${browser}`, title: `${config.displayName} manifest`, status: 'error', message: `Failed to parse manifest: ${parsed.error}`, details: { path: found, fix: [`${constant_1.COMMAND_NAME} register --browser ${browser}`] }, }); nextSteps.push(`${constant_1.COMMAND_NAME} register --browser ${browser}`); continue; } const manifest = parsed.value; const issues = []; if (manifest.name !== constant_1.HOST_NAME) issues.push(`name != ${constant_1.HOST_NAME}`); if (manifest.type !== 'stdio') issues.push(`type != stdio`); if (typeof manifest.path !== 'string') issues.push('path is missing'); if (typeof manifest.path === 'string') { const actual = normalizeComparablePath(manifest.path); const expected = normalizeComparablePath(wrapperPath); if (actual !== expected) issues.push('path does not match installed wrapper'); if (!fs_1.default.existsSync(manifest.path)) issues.push('path target does not exist'); } const allowedOrigins = manifest.allowed_origins; if (!Array.isArray(allowedOrigins) || !allowedOrigins.includes(expectedOrigin)) { issues.push(`allowed_origins missing ${expectedOrigin}`); } checks.push({ id: `manifest.${browser}`, title: `${config.displayName} manifest`, status: issues.length === 0 ? 'ok' : 'error', message: issues.length === 0 ? found : `Invalid manifest (${issues.join('; ')})`, details: { path: found, expectedWrapperPath: wrapperPath, expectedOrigin, fix: issues.length === 0 ? undefined : [`${constant_1.COMMAND_NAME} register --browser ${browser}`], }, }); if (issues.length > 0) nextSteps.push(`${constant_1.COMMAND_NAME} register --browser ${browser}`); } // Check 6: Windows registry (Windows only) if (process.platform === 'win32') { for (const browser of browsersToCheck) { const config = (0, browser_config_1.getBrowserConfig)(browser); const keySpecs = [ config.registryKey ? { key: config.registryKey, expected: config.userManifestPath } : null, config.systemRegistryKey ? { key: config.systemRegistryKey, expected: config.systemManifestPath } : null, ].filter(Boolean); if (keySpecs.length === 0) continue; let anyValue = false; let anyExistingTarget = false; let anyMissingTarget = false; let anyMismatch = false; const results = []; for (const spec of keySpecs) { const res = queryWindowsRegistryDefaultValue(spec.key); if (!res.value) { results.push({ key: spec.key, expected: spec.expected, error: res.error }); continue; } anyValue = true; // Expand environment variables for REG_EXPAND_SZ values const expandedValue = expandWindowsEnvVars(stripOuterQuotes(res.value)); const exists = fs_1.default.existsSync(expandedValue); const matchesExpected = normalizeComparablePath(expandedValue) === normalizeComparablePath(spec.expected); if (exists) { anyExistingTarget = true; if (!matchesExpected) anyMismatch = true; } else { anyMissingTarget = true; } results.push({ key: spec.key, expected: spec.expected, value: res.value, valueType: res.valueType, expandedValue: expandedValue !== res.value ? expandedValue : undefined, exists, matchesExpected, }); } let status = 'error'; let message = 'Registry entry not found'; if (!anyValue) { status = 'error'; message = 'Registry entry not found'; } else if (!anyExistingTarget) { status = 'error'; message = 'Registry entry points to missing manifest'; } else if (anyMissingTarget || anyMismatch) { status = 'warn'; message = 'Registry entry found but inconsistent'; } else { status = 'ok'; message = 'Registry entry points to manifest'; } checks.push({ id: `registry.${browser}`, title: `${config.displayName} registry`, status, message, details: { keys: keySpecs.map((s) => s.key), results, fix: status === 'ok' ? undefined : [`${constant_1.COMMAND_NAME} register --browser ${browser}`], }, }); if (status !== 'ok') nextSteps.push(`${constant_1.COMMAND_NAME} register --browser ${browser}`); } } // Check 7: Port configuration if (fs_1.default.existsSync(stdioConfigPath)) { const cfg = readJsonFile(stdioConfigPath); if (!cfg.ok) { checks.push({ id: 'port.config', title: 'Port config', status: 'error', message: `Failed to parse stdio-config.json: ${cfg.error}`, }); } else { try { const configValue = cfg.value; const url = new URL(configValue.url); const port = Number(url.port); const portOk = port === EXPECTED_PORT; checks.push({ id: 'port.config', title: 'Port config', status: portOk ? 'ok' : 'error', message: configValue.url, details: { expectedPort: EXPECTED_PORT, actualPort: port, fix: portOk ? undefined : [`${constant_1.COMMAND_NAME} update-port ${EXPECTED_PORT}`], }, }); if (!portOk) nextSteps.push(`${constant_1.COMMAND_NAME} update-port ${EXPECTED_PORT}`); // Check constant consistency const nativePortOk = constant_2.NATIVE_SERVER_PORT === EXPECTED_PORT; checks.push({ id: 'port.constant', title: 'Port constant', status: nativePortOk ? 'ok' : 'warn', message: `NATIVE_SERVER_PORT=${constant_2.NATIVE_SERVER_PORT}`, details: { expectedPort: EXPECTED_PORT }, }); // Connectivity check const pingUrl = new URL('/ping', url); const ping = await checkConnectivity(pingUrl.toString(), 1500); checks.push({ id: 'connectivity', title: 'Connectivity', status: ping.ok ? 'ok' : 'warn', message: ping.ok ? `GET ${pingUrl} -> ${ping.status}` : `GET ${pingUrl} failed (${ping.error || 'unknown error'})`, details: { hint: 'If the server is not running, click "Connect" in the extension and retry.', }, }); if (!ping.ok) nextSteps.push('Click "Connect" in the extension, then re-run doctor'); } catch (e) { checks.push({ id: 'port.config', title: 'Port config', status: 'error', message: `Invalid URL in stdio-config.json: ${stringifyError(e)}`, }); } } } // Check 8: Logs directory checks.push({ id: 'logs', title: 'Logs', status: fs_1.default.existsSync(logDir) ? 'ok' : 'warn', message: logDir, details: { hint: 'Wrapper logs are created when Chrome launches the native host.', }, }); // Compute summary const summary = computeSummary(checks); const ok = summary.error === 0; const report = { schemaVersion: SCHEMA_VERSION, timestamp: new Date().toISOString(), ok, summary, environment: { platform: process.platform, arch: process.arch, node: { version: process.version, execPath: process.execPath }, package: { name: packageName, version: packageVersion, rootDir, distDir }, command: { canonical: commandInfo.canonical, aliases: commandInfo.aliases }, nativeHost: { hostName: constant_1.HOST_NAME, expectedPort: EXPECTED_PORT }, }, fixes, checks, nextSteps: Array.from(new Set(nextSteps)).slice(0, 10), }; return report; } /** * Run doctor command with console output. */ async function runDoctor(options) { var _a; const report = await collectDoctorReport(options); const packageVersion = report.environment.package.version; // Output if (options.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { console.log(`${constant_1.COMMAND_NAME} doctor v${packageVersion}\n`); for (const check of report.checks) { console.log(`${statusBadge(check.status)} ${check.title}: ${check.message}`); const fix = (_a = check.details) === null || _a === void 0 ? void 0 : _a.fix; if (check.status !== 'ok' && fix && fix.length > 0) { console.log(` Fix: ${fix[0]}`); } } if (report.fixes.length > 0) { console.log('\nFix attempts:'); for (const f of report.fixes) { const badge = f.success ? (0, utils_1.colorText)('[OK]', 'green') : (0, utils_1.colorText)('[ERROR]', 'red'); console.log(`${badge} ${f.description}${f.success ? '' : ` (${f.error})`}`); } } if (report.nextSteps.length > 0) { console.log('\nNext steps:'); report.nextSteps.forEach((s, i) => console.log(` ${i + 1}. ${s}`)); } } return report.ok ? 0 : 1; } //# sourceMappingURL=doctor.js.map