#!/usr/bin/env node import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DEFAULT_VIEWER_PORT, VIEWER_SERVER_API_VERSION, VIEWER_SERVER_APP_ID, } from "../src/server/viewerServerInfo.mjs"; import { readViewerServerRegistry, } from "../src/server/viewerServerRegistry.mjs"; const scriptPath = fileURLToPath(import.meta.url); const defaultPackageRoot = path.resolve(path.dirname(scriptPath), ".."); const startModeFlag = "--viewer-start-mode"; const defaultRootDirFlag = "--dir"; const startModes = new Set(["auto", "dev", "serve"]); const defaultAgentHost = "127.0.0.1"; const defaultPortScanLimit = 64; const probeTimeoutMs = 350; const activationTimeoutMs = 30_000; const directoryActivationFeature = "directory-activation"; function requiredValue(argv, index, flag) { const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`${flag} requires a value`); } return value; } function normalizeStartMode(value, flag = startModeFlag) { const mode = String(value || "").trim(); if (!startModes.has(mode)) { throw new Error(`${flag} must be one of: auto, dev, serve.`); } return mode; } function parseAgentPort(value, flag = "--port") { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { throw new Error(`${flag} must be a TCP port from 1 to 65535`); } return parsed; } function parsePositiveInteger(value, flag) { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`${flag} must be a positive integer`); } return parsed; } export function normalizeAgentDirectory(value, { fsImpl = fs } = {}) { const rawDir = String(value || "").trim(); if (!rawDir) { throw new Error("agent:start requires --dir "); } if (!path.isAbsolute(rawDir)) { throw new Error("agent:start --dir must be an absolute path"); } const resolvedDir = path.resolve(rawDir); let stats = null; try { stats = fsImpl.statSync(resolvedDir); } catch { throw new Error(`agent:start --dir directory not found: ${resolvedDir}`); } if (!stats?.isDirectory?.()) { throw new Error(`agent:start --dir is not a directory: ${resolvedDir}`); } return resolvedDir; } export function replaceForwardedDefaultRootDir(argv = [], rootDir) { const normalizedRootDir = String(rootDir || "").trim(); const nextArgs = []; let replaced = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith(`${defaultRootDirFlag}=`)) { nextArgs.push(`${defaultRootDirFlag}=${normalizedRootDir}`); replaced = true; continue; } if (arg === defaultRootDirFlag) { nextArgs.push(arg, normalizedRootDir); replaced = true; index += 1; continue; } nextArgs.push(arg); } if (!replaced) { nextArgs.push(defaultRootDirFlag, normalizedRootDir); } return nextArgs; } export function parseAgentStartArgs(argv = [], { fsImpl = fs } = {}) { const options = { startMode: "auto", forwardedArgs: [], directory: "", portScanLimit: defaultPortScanLimit, jsonResult: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith(`${startModeFlag}=`)) { options.startMode = normalizeStartMode(arg.slice(startModeFlag.length + 1)); continue; } if (arg === startModeFlag) { options.startMode = normalizeStartMode(requiredValue(argv, index, arg)); index += 1; continue; } if (arg.startsWith("--port-scan-limit=")) { options.portScanLimit = parsePositiveInteger(arg.slice("--port-scan-limit=".length), "--port-scan-limit"); continue; } if (arg === "--port-scan-limit") { options.portScanLimit = parsePositiveInteger(requiredValue(argv, index, arg), arg); index += 1; continue; } if (arg.startsWith("--shutdown-after=") || arg === "--shutdown-after") { throw new Error("agent:start does not support --shutdown-after"); } if (arg === "--json") { options.jsonResult = true; continue; } options.forwardedArgs.push(arg); } options.directory = normalizeAgentDirectory(forwardedDefaultRootDir(options.forwardedArgs), { fsImpl }); options.forwardedArgs = replaceForwardedDefaultRootDir(options.forwardedArgs, options.directory); return options; } export function forwardedDefaultRootDir(argv = []) { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith(`${defaultRootDirFlag}=`)) { return arg.slice(defaultRootDirFlag.length + 1).trim(); } if (arg === defaultRootDirFlag) { return requiredValue(argv, index, arg).trim(); } } return ""; } export function stripDefaultRootDirArgs(argv = []) { const stripped = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith(`${defaultRootDirFlag}=`)) { continue; } if (arg === defaultRootDirFlag) { index += 1; continue; } stripped.push(arg); } return stripped; } export function forwardedServerTarget(argv = []) { const target = { host: defaultAgentHost, port: DEFAULT_VIEWER_PORT, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith("--host=")) { target.host = arg.slice("--host=".length).trim() || defaultAgentHost; continue; } if (arg === "--host") { target.host = requiredValue(argv, index, arg).trim() || defaultAgentHost; index += 1; continue; } if (arg.startsWith("--port=")) { target.port = parseAgentPort(arg.slice("--port=".length), "--port"); continue; } if (arg === "--port") { target.port = parseAgentPort(requiredValue(argv, index, arg), arg); index += 1; } } return target; } export function replaceForwardedPort(argv = [], port = DEFAULT_VIEWER_PORT) { const normalizedPort = parseAgentPort(port, "--port"); const nextArgs = []; let replaced = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg.startsWith("--port=")) { nextArgs.push(`--port=${normalizedPort}`); replaced = true; continue; } if (arg === "--port") { nextArgs.push(arg, String(normalizedPort)); replaced = true; index += 1; continue; } nextArgs.push(arg); } if (!replaced) { nextArgs.push("--port", String(normalizedPort)); } return nextArgs; } export function isSymlinkPath(filePath) { if (!filePath) { return false; } try { return fs.lstatSync(filePath).isSymbolicLink(); } catch { return false; } } export function selectAgentStartMode({ requestedMode = "auto", npmConfigPrefix = "", npmPackageJson = "", } = {}) { const mode = normalizeStartMode(requestedMode); if (mode !== "auto") { return mode; } const packagePrefix = npmConfigPrefix || (npmPackageJson ? path.dirname(npmPackageJson) : "") || process.env.npm_config_prefix || (process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : ""); return isSymlinkPath(packagePrefix) ? "dev" : "serve"; } function safeRealpath(filePath) { const resolvedPath = path.resolve(String(filePath || "")); try { return fs.realpathSync(resolvedPath); } catch { return resolvedPath; } } function gitOutput(args, cwd) { try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return ""; } } function resolveGitDir(rawGitDir, cwd) { if (!rawGitDir) { return ""; } return safeRealpath(path.isAbsolute(rawGitDir) ? rawGitDir : path.resolve(cwd, rawGitDir)); } function normalizeGitBranch(branch) { const normalizedBranch = String(branch || "").trim(); return normalizedBranch && normalizedBranch !== "HEAD" ? normalizedBranch : "detached"; } export function buildAgentViewerGit({ env = process.env, cwd = process.cwd(), } = {}) { const resolvedCwd = safeRealpath(env.INIT_CWD || cwd); const gitDir = resolveGitDir(gitOutput(["rev-parse", "--git-dir"], resolvedCwd), resolvedCwd); if (!gitDir) { return ""; } const gitBranch = gitOutput(["symbolic-ref", "--quiet", "--short", "HEAD"], resolvedCwd) || gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], resolvedCwd); return `${gitDir}#${normalizeGitBranch(gitBranch)}`; } function envWithGit(env, git) { return { ...env, VIEWER_GIT: String(git || ""), }; } function envWithAgentStartMode(env, mode) { return { ...env, VIEWER_AGENT_START_MODE: normalizeStartMode(mode), }; } function envWithGitAndMode(env, git, mode) { return envWithAgentStartMode(envWithGit(env, git), mode); } function envWithGitModeAndDefaultDir(env, git, mode, defaultDir = "") { const nextEnv = envWithGitAndMode(env, git, mode); const normalizedDir = String(defaultDir || "").trim(); if (normalizedDir) { nextEnv.VIEWER_DEFAULT_DIR = normalizedDir; } return nextEnv; } export function readAgentViewerPackageVersion(packageRoot = defaultPackageRoot, { fsImpl = fs } = {}) { try { const packageJson = JSON.parse(fsImpl.readFileSync(path.join(path.resolve(packageRoot), "package.json"), "utf8")); return String(packageJson.version || "").trim(); } catch { return ""; } } export function buildAgentStartCommand({ mode, packageRoot = defaultPackageRoot, forwardedArgs = [], env = process.env, nodePath = process.execPath, git = buildAgentViewerGit({ env }), } = {}) { const resolvedPackageRoot = path.resolve(packageRoot); if (mode === "dev") { const nextEnv = envWithGitModeAndDefaultDir(env, git, mode, forwardedDefaultRootDir(forwardedArgs)); return { command: nodePath, args: [ path.join(resolvedPackageRoot, "node_modules", "vite", "bin", "vite.js"), "dev", ...stripDefaultRootDirArgs(forwardedArgs), ], cwd: resolvedPackageRoot, env: nextEnv, mode, }; } return { command: nodePath, args: [ path.join(resolvedPackageRoot, "src", "server", "server.mjs"), ...forwardedArgs, ], cwd: resolvedPackageRoot, env: envWithGitAndMode(env, git, mode), mode, }; } export function resolveAgentStartCommand({ argv = process.argv.slice(2), env = process.env, packageRoot = defaultPackageRoot, nodePath = process.execPath, git = buildAgentViewerGit({ env }), } = {}) { const parsed = parseAgentStartArgs(argv); const mode = selectAgentStartMode({ requestedMode: parsed.startMode, npmConfigPrefix: env.npm_config_prefix, npmPackageJson: env.npm_package_json, }); return buildAgentStartCommand({ mode, packageRoot, forwardedArgs: parsed.forwardedArgs, env, nodePath, git, }); } function normalizeBaseUrl(host, port) { return `http://${host}:${port}`; } export function agentViewerUrl(baseUrl, directory) { const url = new URL("/", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`); url.searchParams.set("dir", normalizeAgentDirectory(directory)); return url.toString(); } function normalizeReuseMode(mode) { const normalizedMode = String(mode || "").trim(); if (normalizedMode === "dev") { return "dev"; } if (normalizedMode === "serve" || normalizedMode === "dist" || normalizedMode === "bundle") { return "serve"; } return ""; } function normalizeReuseContext(contextOrGit = {}) { if (typeof contextOrGit === "string") { return { git: contextOrGit, mode: "dev", viewerVersion: "", }; } return { git: String(contextOrGit?.git || ""), mode: normalizeReuseMode(contextOrGit?.mode), viewerVersion: String(contextOrGit?.viewerVersion || "").trim(), }; } function serverInfoMode(serverInfo) { return normalizeReuseMode(serverInfo?.serverMode || serverInfo?.runtimeMode || serverInfo?.mode); } function serverInfoRequiresGitMatch(serverInfo, context) { const currentMode = normalizeReuseMode(context?.mode); const serverMode = serverInfoMode(serverInfo); return !currentMode || !serverMode || currentMode === "dev" || serverMode === "dev"; } function serverInfoGitAllowsReuse(serverInfo, context) { if (!serverInfoRequiresGitMatch(serverInfo, context)) { return true; } const currentGit = String(context?.git || ""); const serverGit = String(serverInfo?.git || ""); return !currentGit || !serverGit || currentGit === serverGit; } function serverInfoVersionAllowsReuse(serverInfo, context) { if (serverInfoRequiresGitMatch(serverInfo, context)) { return true; } const currentVersion = String(context?.viewerVersion || "").trim(); const serverVersion = String(serverInfo?.viewerVersion || "").trim(); return Boolean(currentVersion && serverVersion && currentVersion === serverVersion); } function serverInfoHasFeature(serverInfo, feature) { const features = Array.isArray(serverInfo?.serverFeatures) ? serverInfo.serverFeatures : []; return features.includes(feature); } export function isReusableAgentViewerServer(serverInfo, contextOrGit = {}) { const context = normalizeReuseContext(contextOrGit); return Boolean( serverInfo && serverInfo.app === VIEWER_SERVER_APP_ID && Number(serverInfo.serverApiVersion || 0) >= VIEWER_SERVER_API_VERSION && serverInfo.dynamicRoot === true && serverInfoHasFeature(serverInfo, directoryActivationFeature) && serverInfoGitAllowsReuse(serverInfo, context) && serverInfoVersionAllowsReuse(serverInfo, context) ); } async function fetchWithTimeout(fetchImpl, url, optionsOrTimeoutMs = {}, timeoutMs = probeTimeoutMs) { const requestOptions = typeof optionsOrTimeoutMs === "number" ? {} : { ...optionsOrTimeoutMs }; const effectiveTimeoutMs = typeof optionsOrTimeoutMs === "number" ? optionsOrTimeoutMs : timeoutMs; const controller = typeof AbortController === "function" ? new AbortController() : null; const timeout = controller ? setTimeout(() => controller.abort(), effectiveTimeoutMs) : null; try { return await fetchImpl(url, controller ? { ...requestOptions, signal: controller.signal } : requestOptions); } finally { if (timeout) { clearTimeout(timeout); } } } function probeBlockedByPermissions(error) { const code = String(error?.cause?.code || error?.code || ""); return code === "EPERM" || code === "EACCES"; } export async function probeAgentViewerPort({ host = defaultAgentHost, port = DEFAULT_VIEWER_PORT, fetchImpl = globalThis.fetch, timeoutMs = probeTimeoutMs, } = {}) { if (typeof fetchImpl !== "function") { return { status: "unknown", port, error: "fetch is unavailable" }; } const baseUrl = normalizeBaseUrl(host, port); try { const response = await fetchWithTimeout(fetchImpl, `${baseUrl}/__cad/server`, timeoutMs); if (!response?.ok) { return { status: "occupied", port, baseUrl }; } try { const serverInfo = await response.json(); if (serverInfo?.app === VIEWER_SERVER_APP_ID) { return { status: "viewer", port, baseUrl, serverInfo }; } } catch { // Non-JSON responses mean another process is using this port. } return { status: "occupied", port, baseUrl }; } catch (error) { if (error?.name === "AbortError") { return { status: "occupied", port, baseUrl, error }; } if (probeBlockedByPermissions(error)) { return { status: "blocked", port, baseUrl, error }; } return { status: "closed", port, baseUrl, error }; } } async function responseSnippet(response) { try { const text = await response.text(); return String(text || "").trim().slice(0, 500); } catch { return ""; } } export async function activateAgentViewerDirectory({ baseUrl, directory, fetchImpl = globalThis.fetch, timeoutMs = activationTimeoutMs, } = {}) { if (typeof fetchImpl !== "function") { throw new Error("fetch is unavailable; cannot activate CAD Viewer directory"); } const normalizedDirectory = normalizeAgentDirectory(directory); const activationUrl = new URL("/__cad/directory/activate", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`); activationUrl.searchParams.set("dir", normalizedDirectory); let response = null; try { response = await fetchWithTimeout(fetchImpl, String(activationUrl), { method: "POST", }, timeoutMs); } catch (error) { if (probeBlockedByPermissions(error)) { throw new Error(`CAD Viewer directory activation was blocked for ${baseUrl}; rerun agent:start with local network permission.`); } throw error; } if (!response?.ok) { const detail = response ? await responseSnippet(response) : ""; const status = response ? `${response.status || "unknown"} ${response.statusText || ""}`.trim() : "unknown"; throw new Error(`Failed to activate CAD Viewer directory ${normalizedDirectory}: ${status}${detail ? ` - ${detail}` : ""}`); } let payload = null; try { payload = await response.json(); } catch { throw new Error(`CAD Viewer directory activation did not return JSON for ${normalizedDirectory}`); } if (!payload?.ok) { throw new Error(`CAD Viewer directory activation failed for ${normalizedDirectory}`); } return { directory: normalizedDirectory, viewerUrl: agentViewerUrl(baseUrl, normalizedDirectory), activeDirectory: payload.directory || null, serverInfo: payload.server || null, }; } function registryHost(serverInfo, fallbackHost) { try { const url = new URL(String(serverInfo?.url || "")); return url.hostname || fallbackHost; } catch { return fallbackHost; } } export async function resolveAgentViewerPort({ forwardedArgs = [], git = "", mode = "dev", viewerVersion = "", registryServers = readViewerServerRegistry(), probePort = probeAgentViewerPort, portScanLimit = defaultPortScanLimit, } = {}) { const target = forwardedServerTarget(forwardedArgs); const reuseContext = { git, mode, viewerVersion }; const reusableRegistryServers = registryServers .filter((serverInfo) => isReusableAgentViewerServer(serverInfo, reuseContext)) .sort((left, right) => Number(left.port) - Number(right.port)); for (const serverInfo of reusableRegistryServers) { const host = registryHost(serverInfo, target.host); const probe = await probePort({ host, port: serverInfo.port }); if (probe.status === "blocked") { throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`); } if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) { return { action: "reuse", host, port: Number(serverInfo.port), baseUrl: probe.baseUrl, serverInfo: probe.serverInfo, }; } } for (let offset = 0; offset < portScanLimit; offset += 1) { const port = target.port + offset; if (port > 65535) { break; } const probe = await probePort({ host: target.host, port }); if (probe.status === "blocked") { throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`); } if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) { return { action: "reuse", host: target.host, port, baseUrl: probe.baseUrl, serverInfo: probe.serverInfo, }; } if (probe.status === "closed") { return { action: "start", host: target.host, port, baseUrl: probe.baseUrl, }; } } throw new Error(`No reusable or free CAD Viewer port found from ${target.port} through ${Math.min(target.port + portScanLimit - 1, 65535)}.`); } export async function resolveAgentStartLaunch({ argv = process.argv.slice(2), env = process.env, packageRoot = defaultPackageRoot, nodePath = process.execPath, probePort = probeAgentViewerPort, registryServers = readViewerServerRegistry(), } = {}) { const parsed = parseAgentStartArgs(argv); const git = buildAgentViewerGit({ env }); const mode = selectAgentStartMode({ requestedMode: parsed.startMode, npmConfigPrefix: env.npm_config_prefix, npmPackageJson: env.npm_package_json, }); const viewerVersion = readAgentViewerPackageVersion(packageRoot); const portResolution = await resolveAgentViewerPort({ forwardedArgs: parsed.forwardedArgs, git, mode, viewerVersion, registryServers, probePort, portScanLimit: parsed.portScanLimit, }); if (portResolution.action === "reuse") { return { ...portResolution, git, directory: parsed.directory, viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory), jsonResult: parsed.jsonResult, }; } return { action: "start", host: portResolution.host, port: portResolution.port, baseUrl: portResolution.baseUrl, git, directory: parsed.directory, viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory), jsonResult: parsed.jsonResult, command: buildAgentStartCommand({ mode, packageRoot, forwardedArgs: replaceForwardedPort(parsed.forwardedArgs, portResolution.port), env, nodePath, git, }), }; } export async function runAgentStart(options = {}) { const launch = await resolveAgentStartLaunch(options); if (launch.action === "reuse") { await activateAgentViewerDirectory({ baseUrl: launch.baseUrl, directory: launch.directory, fetchImpl: options.fetchImpl, }); console.log(`CAD Viewer already running at ${launch.viewerUrl}`); console.log(`CAD Viewer URL: ${launch.viewerUrl}`); console.log(`CAD Viewer git: ${launch.git || "none"}`); if (launch.jsonResult) { console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "reuse" })); } return null; } const command = launch.command; console.log(`Starting CAD Viewer ${command.mode} server at ${launch.viewerUrl}`); console.log(`CAD Viewer URL: ${launch.viewerUrl}`); console.log(`CAD Viewer git: ${launch.git || "none"}`); if (launch.jsonResult) { console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "start" })); } const child = spawn(command.command, command.args, { cwd: command.cwd, env: command.env, stdio: "inherit", }); child.on("exit", (code, signal) => { if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 1); }); child.on("error", (error) => { console.error(`Failed to start CAD Viewer ${command.mode} server: ${error.message}`); process.exit(1); }); return child; } const isMain = process.argv[1] && path.resolve(process.argv[1]) === scriptPath; if (isMain) { try { await runAgentStart(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } }