import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle"; import { CACHE_DIR, DATA_DIR, DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, DEFAULT_WEB_PORT, HEALTH_TIMEOUT_MS_RELEASE, } from "./constants"; import { ensureAsset, getRelease } from "./github"; import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health"; import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; import { attachBackendExitHandler, logStartupInfo } from "./shared"; import { launchWebApp, openBrowser } from "./web"; export type RunOptions = { version?: string; backendPort?: number; webPort?: number; /** Show info logs from backend + web */ verbose?: boolean; /** Show debug logs + agent message dumps */ debug?: boolean; }; type PreparedRelease = { bundleDir: string; backendBin: string; backendUrl: string; backendEnv: NodeJS.ProcessEnv; webEnv: NodeJS.ProcessEnv; releaseTag: string; requestedVersion?: string; webPort: number; agentctlPort: number; dbPath: string; logLevel: string; showOutput: boolean; }; /** * Find a cached release binary to use when GitHub is unreachable. * If version is specified, checks that exact tag. Otherwise, picks * the highest semver tag available in the cache. */ export function findCachedRelease( platformDir: string, version?: string, ): { cacheDir: string; tag: string } | null { if (version) { const cacheDir = path.join(CACHE_DIR, version, platformDir); const bundleDir = path.join(cacheDir, "kandev"); const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); if (fs.existsSync(backendBin)) { return { cacheDir, tag: version }; } return null; } // No version specified — scan for cached tags and pick the latest. if (!fs.existsSync(CACHE_DIR)) return null; const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v")); if (entries.length === 0) return null; const sorted = sortVersionsDesc(entries); for (const tag of sorted) { const cacheDir = path.join(CACHE_DIR, tag, platformDir); const bundleDir = path.join(cacheDir, "kandev"); const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); if (fs.existsSync(backendBin)) { return { cacheDir, tag }; } } return null; } /** * Remove old cached releases, keeping only the 2 most recent tags. * Runs after a successful download so we don't accumulate stale versions. * The previous version is kept as a fallback for offline use. */ export function cleanOldReleases(currentTag: string) { try { if (!fs.existsSync(CACHE_DIR)) return; const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v")); if (entries.length <= 2) return; const sorted = sortVersionsDesc(entries); // Always keep currentTag + the next most recent. const keep = new Set([currentTag, sorted[0], sorted[1]]); for (const entry of entries) { if (!keep.has(entry)) { fs.rmSync(path.join(CACHE_DIR, entry), { recursive: true, force: true }); } } } catch { // Non-critical — don't fail the launch if cleanup errors. } } async function prepareReleaseBundle({ version, backendPort, webPort, verbose = false, debug = false, }: RunOptions): Promise { const platformDir = getPlatformDir(); let tag: string; let cacheDir: string; try { const release = await getRelease(version); tag = release.tag_name; const assetName = `kandev-${platformDir}.tar.gz`; cacheDir = path.join(CACHE_DIR, tag, platformDir); const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { const percent = total ? Math.round((downloaded / total) * 100) : 0; const mb = (downloaded / (1024 * 1024)).toFixed(1); const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); }); process.stderr.write("\n"); ensureExtracted(archivePath, cacheDir); cleanOldReleases(tag); } catch (err) { // GitHub unreachable — try to launch from cache. const cached = findCachedRelease(platformDir, version); if (!cached) { const target = version ? `version ${version}` : "latest version"; const reason = err instanceof Error ? err.message : String(err); throw new Error( `Failed to fetch ${target} and no cached release found.\n` + ` Reason: ${reason}\n` + ` Run kandev once while online to cache a release for offline use.`, ); } tag = cached.tag; cacheDir = cached.cacheDir; process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`); } const bundleDir = findBundleRoot(cacheDir); const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); if (!fs.existsSync(backendBin)) { throw new Error(`Backend binary not found at ${backendBin}`); } const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); if (!fs.existsSync(agentctlBin)) { throw new Error(`agentctl binary not found at ${agentctlBin}`); } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT)); const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); const backendUrl = `http://localhost:${actualBackendPort}`; const showOutput = verbose || debug; const logLevel = process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn"); fs.mkdirSync(DATA_DIR, { recursive: true }); const dbPath = path.join(DATA_DIR, "kandev.db"); // Note: Release mode doesn't configure MCP server ports as it uses // the bundled configuration. Only backend and agentctl ports are set. // Log level defaults to warn for clean output and can be overridden // via KANDEV_LOG_LEVEL or --verbose/--debug flags. const backendEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_SERVER_PORT: String(actualBackendPort), KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`, KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), KANDEV_DATABASE_PATH: dbPath, KANDEV_LOG_LEVEL: logLevel, ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), }; const webEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_API_BASE_URL: backendUrl, PORT: String(actualWebPort), // Ensure Next.js standalone server binds to 127.0.0.1 so localhost health checks work. // Without this, HOSTNAME from the host environment can cause binding issues. HOSTNAME: "127.0.0.1", }; (webEnv as Record).NODE_ENV = "production"; return { bundleDir, backendBin, backendUrl, backendEnv, webEnv, releaseTag: tag, requestedVersion: version, webPort: actualWebPort, agentctlPort, dbPath, logLevel, showOutput, }; } /** * Attach a ring buffer to a readable stream, keeping roughly the last `maxChars` * characters. Note: the limit is measured in JS string length (UTF-16 code units), * not bytes — fine for log output which is overwhelmingly ASCII. */ export function attachRingBuffer( stream: NodeJS.ReadableStream | null, maxChars = 64 * 1024, ): () => string { let buf = ""; stream?.on("data", (chunk: Buffer | string) => { buf += typeof chunk === "string" ? chunk : chunk.toString("utf8"); if (buf.length > maxChars) { buf = buf.slice(buf.length - maxChars); } }); return () => buf; } function launchReleaseApps(prepared: PreparedRelease): { supervisor: ReturnType; backendProc: ReturnType; webServerPath: string; dumpBackendLogs: () => void; } { const releaseSource = prepared.requestedVersion ? `(requested: ${prepared.requestedVersion})` : "(github latest)"; logStartupInfo({ header: `release: ${prepared.releaseTag} ${releaseSource}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), webPort: prepared.webPort, agentctlPort: prepared.agentctlPort, mcpPort: 0, backendUrl: prepared.backendUrl, mcpUrl: "", }, dbPath: prepared.dbPath, logLevel: prepared.logLevel, }); const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); // Start backend: ignore stdin, always show stderr immediately. // In verbose/debug mode stream stdout live; otherwise capture it into a ring // buffer so we can dump the last few KB if the healthcheck fails (users were // previously seeing opaque "timed out" errors with no backend context). const backendProc = spawn(prepared.backendBin, [], { cwd: path.dirname(prepared.backendBin), env: prepared.backendEnv, stdio: prepared.showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "inherit"], }); supervisor.children.push(backendProc); const readBuffered = prepared.showOutput ? () => "" : attachRingBuffer(backendProc.stdout); let dumped = false; const dumpBackendLogs = (): void => { if (dumped) return; dumped = true; const buffered = readBuffered(); if (buffered.trim().length === 0) return; console.error("[kandev] --- backend stdout (last captured output) ---"); console.error(buffered.trimEnd()); console.error("[kandev] --- end backend stdout ---"); }; attachBackendExitHandler(backendProc, supervisor); const webServerPath = resolveWebServerPath(prepared.bundleDir); if (!webServerPath) { throw new Error("Web server entry (server.js) not found in bundle"); } return { supervisor, backendProc, webServerPath, dumpBackendLogs }; } export async function runRelease({ version, backendPort, webPort, verbose = false, debug = false, }: RunOptions): Promise { const prepared = await prepareReleaseBundle({ version, backendPort, webPort, verbose, debug }); const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchReleaseApps(prepared); const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); console.log("[kandev] starting backend..."); await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs); console.log(`[kandev] backend ready at ${prepared.backendUrl}`); const webUrl = `http://localhost:${prepared.webPort}`; console.log("[kandev] starting web..."); const webProc = launchWebApp({ command: "node", args: [webServerPath], cwd: path.dirname(webServerPath), env: prepared.webEnv, supervisor, label: "web", quiet: !prepared.showOutput, }); await waitForUrlReady(webUrl, webProc, healthTimeoutMs); console.log("[kandev] ready at " + prepared.backendUrl); openBrowser(prepared.backendUrl); }