422 lines
14 KiB
JavaScript
422 lines
14 KiB
JavaScript
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,
|
||
|
|
};
|