import fs from 'fs'; import path from 'path'; import { DEFAULT_PORT } from './cli-args.js'; import { getRunDir, readDesktopLocalPortFromSettings } from './cli-paths.js'; import { resolveApiHost, buildLocalUrl } from './cli-network.js'; import { fetchTunnelProvidersFromPort, fetchSystemInfoFromPort, isServerHealthReady } from './cli-http.js'; import { isPortAvailable } from './cli-ports.js'; import { getPidFilePath, getInstanceFilePath, readPidFile, removePidFile, readInstanceOptions, removeInstanceFile, getOpenchamberProcessState, hasOpenchamberRuntimeInfo, } from './cli-process.js'; import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './cli-tunnel-capabilities.js'; function createLivePortInstance(port, info, host) { if (!hasOpenchamberRuntimeInfo(info)) return null; return { port, pid: Number.isFinite(info.pid) ? info.pid : null, pidFilePath: path.join(getRunDir(), `openchamber-${port}.pid`), instanceFilePath: path.join(getRunDir(), `openchamber-${port}.json`), mtime: 0, startedAt: 0, launchMode: 'daemon', runtime: info.runtime, source: 'probe', host: typeof host === 'string' && host.length > 0 ? host : undefined, }; } function normalizeProbeHost(host) { return typeof host === 'string' && host.trim().length > 0 ? host.trim() : undefined; } function isWildcardProbeHost(host) { const normalized = normalizeProbeHost(host); return normalized === '0.0.0.0' || normalized === '::' || normalized === '[::]'; } function isLoopbackProbeHost(host) { const normalized = normalizeProbeHost(host); return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]'; } function isConcreteProbeHost(host) { const normalized = normalizeProbeHost(host); return Boolean(normalized && !isWildcardProbeHost(normalized) && !isLoopbackProbeHost(normalized)); } function getSystemInfoProbeHosts(...hosts) { const out = []; const hasConcreteAuthoritativeHost = hosts.some(isConcreteProbeHost); const pushHost = (host, requiresPidMatch = false) => { const normalized = normalizeProbeHost(host); const key = resolveApiHost(normalized); if (!out.some((entry) => resolveApiHost(entry.host) === key)) { out.push({ host: normalized, requiresPidMatch }); } }; for (const host of hosts) { if (normalizeProbeHost(host)) { pushHost(host, false); } } pushHost(undefined, hasConcreteAuthoritativeHost); pushHost('127.0.0.1', hasConcreteAuthoritativeHost); return out; } async function fetchSystemInfoFromPortCandidates(port, fetchImpl, hosts, expectedPid) { for (const { host, requiresPidMatch } of hosts) { const info = await fetchSystemInfoFromPort(port, fetchImpl, host); if (hasOpenchamberRuntimeInfo(info)) { if (requiresPidMatch && info.pid !== expectedPid) { continue; } return { info, host }; } } return { info: null, host: null }; } async function resolveDoctorPortStatuses(options = {}) { const runningEntries = await discoverRunningInstances(); const desktopEntry = await discoverDesktopInstance(); const statuses = []; if (options.explicitPort) { const requestedPort = options.port; const runningMatch = runningEntries.find((entry) => entry.port === requestedPort); if (runningMatch) { statuses.push({ port: requestedPort, available: true, status: 'success', line: `port ${requestedPort} available for tunneling`, detail: 'Double-check this same port is configured in your provider dashboard/config.', }); return { statuses, availableEntries: [runningMatch] }; } if (desktopEntry && desktopEntry.port === requestedPort) { statuses.push({ port: requestedPort, available: false, status: 'warning', line: `port ${requestedPort} not available (desktop runtime)`, detail: 'Use a CLI instance port from `openchamber serve` for tunneling.', }); return { statuses, availableEntries: [] }; } statuses.push({ port: requestedPort, available: false, status: 'error', line: `port ${requestedPort} not available (no running instance)`, detail: `Start one with \`openchamber serve --port ${requestedPort}\`.`, }); return { statuses, availableEntries: [] }; } for (const entry of runningEntries) { statuses.push({ port: entry.port, available: true, status: 'success', line: `port ${entry.port} available for tunneling`, detail: 'Double-check this same port is configured in your provider dashboard/config.', }); } if (desktopEntry && !runningEntries.some((entry) => entry.port === desktopEntry.port)) { statuses.push({ port: desktopEntry.port, available: false, status: 'warning', line: `port ${desktopEntry.port} not available (desktop runtime)`, detail: 'Use a CLI instance port from `openchamber serve` for tunneling.', }); } if (runningEntries.length === 0) { statuses.push({ port: null, available: false, status: 'warning', line: 'no CLI ports available for tunneling', detail: 'Start one with `openchamber serve`.', }); } return { statuses, availableEntries: runningEntries }; } async function discoverRunningInstances(options = {}) { const instances = []; const runDir = getRunDir(); const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch; const getProcessState = typeof options.getOpenchamberProcessState === 'function' ? options.getOpenchamberProcessState : (pid) => getOpenchamberProcessState(pid, options); try { const files = fs.readdirSync(runDir); const pidFiles = files.filter((file) => file.startsWith('openchamber-') && file.endsWith('.pid')); for (const file of pidFiles) { const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''), 10); if (!Number.isFinite(port) || port <= 0) continue; const pidFilePath = path.join(runDir, file); const pid = readPidFile(pidFilePath); if (!pid) { removePidFile(pidFilePath); removeInstanceFile(path.join(runDir, `openchamber-${port}.json`)); continue; } const instanceFilePath = path.join(runDir, `openchamber-${port}.json`); const storedOptions = readInstanceOptions(instanceFilePath); const processState = getProcessState(pid); if (processState === 'dead') { removePidFile(pidFilePath); removeInstanceFile(instanceFilePath); continue; } // A live PID-file is only the right instance if the recorded port also // confirms OpenChamber. Cmdline identity alone can match a recycled PID // from another OpenChamber process on a different port. Try all plausible // hosts first; if matched/unknown identity still can't be confirmed, keep // the registry files but don't claim the instance is running. const { info: liveInfo, host: confirmedHost } = await fetchSystemInfoFromPortCandidates( port, fetchImpl, getSystemInfoProbeHosts(storedOptions?.host, options.host), pid, ); const livePid = Number.isFinite(liveInfo?.pid) ? liveInfo.pid : null; if (!hasOpenchamberRuntimeInfo(liveInfo)) { if (processState === 'mismatched') { removePidFile(pidFilePath); removeInstanceFile(instanceFilePath); } continue; } if (liveInfo.runtime === 'desktop') { removePidFile(pidFilePath); removeInstanceFile(instanceFilePath); continue; } let mtime = 0; let startedAt = 0; try { mtime = fs.statSync(pidFilePath).mtimeMs; } catch { } if (Number.isFinite(storedOptions?.startedAt)) { startedAt = storedOptions.startedAt; } const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon'; instances.push({ port, pid: livePid || (processState === 'matched' ? pid : null), pidFilePath, instanceFilePath, mtime, startedAt, launchMode, runtime: liveInfo.runtime, source: 'registry+probe', host: typeof confirmedHost === 'string' && confirmedHost.length > 0 ? confirmedHost : (typeof storedOptions?.host === 'string' && storedOptions.host.length > 0 ? storedOptions.host : undefined), }); } } catch { } instances.sort((a, b) => a.port - b.port); return instances; } async function discoverOpenChamberInstanceOnPort(port, options = {}) { if (!Number.isFinite(port) || port <= 0) return null; const runningInstances = Array.isArray(options.runningInstances) ? options.runningInstances : await discoverRunningInstances(options); const registryMatch = runningInstances.find((entry) => entry.port === port); if (registryMatch) return registryMatch; const info = await fetchSystemInfoFromPort( port, typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch, options.host, ); if (info?.runtime === 'desktop' && !isDesktopRuntimeForPort(info, port)) { return null; } return createLivePortInstance(port, info, options.host); } async function discoverLifecycleInstances(options = {}, deps = {}) { const runningInstances = await discoverRunningInstances({ ...deps, host: options.host }); if (!options.explicitPort) { return runningInstances; } const found = runningInstances.find((entry) => entry.port === options.port); if (found) return [found]; const liveInstance = await discoverOpenChamberInstanceOnPort(options.port, { ...deps, host: options.host, runningInstances, }); return liveInstance ? [liveInstance] : []; } async function discoverUnconfirmedRegistryInstanceOnPort(port, options = {}) { if (!Number.isFinite(port) || port <= 0) return null; const pidFilePath = await getPidFilePath(port); const pid = readPidFile(pidFilePath); if (!pid) return null; const instanceFilePath = await getInstanceFilePath(port); const storedOptions = readInstanceOptions(instanceFilePath); const processState = getOpenchamberProcessState(pid); if (processState === 'dead') { removePidFile(pidFilePath); removeInstanceFile(instanceFilePath); return null; } if (processState !== 'matched') { return null; } const host = storedOptions?.host || options.host; if (await isPortAvailable(port, host)) { removePidFile(pidFilePath); removeInstanceFile(instanceFilePath); return null; } return { port, pid, pidFilePath, instanceFilePath, mtime: 0, startedAt: Number.isFinite(storedOptions?.startedAt) ? storedOptions.startedAt : 0, launchMode: storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon', runtime: 'cli', source: 'registry-unconfirmed', host: typeof host === 'string' && host.length > 0 ? host : undefined, }; } function getLatestInstance(instances) { if (!instances.length) return null; return [...instances].sort((a, b) => { const startedDelta = (b.startedAt || 0) - (a.startedAt || 0); if (startedDelta !== 0) return startedDelta; const mtimeDelta = (b.mtime || 0) - (a.mtime || 0); if (mtimeDelta !== 0) return mtimeDelta; return b.port - a.port; })[0]; } function isDesktopRuntimeForPort(info, port) { if (info?.runtime !== 'desktop') { return false; } const desktopPort = readDesktopLocalPortFromSettings(); return !desktopPort || desktopPort === port; } async function inspectTunnelAttachability(port, { requireHealthy = true } = {}) { const info = await fetchSystemInfoFromPort(port); if (!info || typeof info.runtime !== 'string') { return { attachable: false, reason: 'unreachable' }; } if (isDesktopRuntimeForPort(info, port)) { return { attachable: false, reason: 'desktop', info }; } if (requireHealthy) { const healthy = await isServerHealthReady(port, 1200); if (!healthy) { return { attachable: false, reason: 'unhealthy', info }; } } return { attachable: true, reason: 'ok', info }; } async function discoverDesktopInstance(fetchImpl = globalThis.fetch) { const port = readDesktopLocalPortFromSettings(); if (!port) { return null; } const info = await fetchSystemInfoFromPort(port, fetchImpl); if (!info || info.runtime !== 'desktop') { return null; } return { port, pid: info.pid, runtime: info.runtime, }; } async function resolveTunnelProviders(options = {}, deps = {}) { const readPorts = typeof deps.readPorts === 'function' ? deps.readPorts : async () => (await discoverRunningInstances()).map((entry) => entry.port); const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : globalThis.fetch; const candidatePorts = []; if (Number.isFinite(options.port) && options.port > 0) { candidatePorts.push(options.port); } const discoveredPorts = await Promise.resolve(readPorts()); if (Array.isArray(discoveredPorts)) { candidatePorts.push(...discoveredPorts); } if (!candidatePorts.includes(DEFAULT_PORT)) { candidatePorts.push(DEFAULT_PORT); } for (const port of candidatePorts) { const providers = await fetchTunnelProvidersFromPort(port, fetchImpl); if (providers) { return { providers, source: `api:${port}` }; } } return { providers: DEFAULT_TUNNEL_PROVIDER_CAPABILITIES, source: 'fallback' }; } export { resolveDoctorPortStatuses, discoverRunningInstances, discoverOpenChamberInstanceOnPort, discoverLifecycleInstances, discoverUnconfirmedRegistryInstanceOnPort, getLatestInstance, isDesktopRuntimeForPort, inspectTunnelAttachability, discoverDesktopInstance, resolveTunnelProviders, };