import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { ensureExtracted, findBundleRoot } from "./bundle"; import { DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, HEALTH_TIMEOUT_MS_RELEASE, resolveCacheDir, resolveDataDir, resolveDatabasePath, resolveKandevHomeDir, } from "./constants"; import { ensureAsset, getRelease } from "./github"; import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; import { resolveRuntime, validateBundle } from "./runtime"; import { buildBackendEnv, createOutputRingBuffer, logStartupInfo, resolveBackendLogLevels, } from "./shared"; import { launchRestartableBackend } from "./supervisor/backend"; import { openBrowser } from "./web"; export type RunOptions = { runtimeVersion?: string; backendPort?: number; /** Show backend info logs on stdout */ verbose?: boolean; /** Retain backend debug logs in its file + agent message dumps */ debug?: boolean; /** Skip browser open. Set by service units. */ headless?: boolean; }; type PreparedBundle = { bundleDir: string; backendBin: string; backendUrl: string; backendEnv: NodeJS.ProcessEnv; releaseTag: string; agentctlPort: number; dbPath: string; logLevel: string; }; /** * 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 { const rootCacheDir = resolveCacheDir(); if (version) { const cacheDir = path.join(rootCacheDir, 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(rootCacheDir)) return null; const entries = fs.readdirSync(rootCacheDir).filter((d) => d.startsWith("v")); if (entries.length === 0) return null; const sorted = sortVersionsDesc(entries); for (const tag of sorted) { const cacheDir = path.join(rootCacheDir, 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) { const rootCacheDir = resolveCacheDir(); try { if (!fs.existsSync(rootCacheDir)) return; const entries = fs.readdirSync(rootCacheDir).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(rootCacheDir, entry), { recursive: true, force: true }); } } } catch { // Non-critical — don't fail the launch if cleanup errors. } } /** * Download a specific release version into the local cache. * Only used when --runtime-version is given explicitly. */ async function downloadRuntimeVersion(runtimeVersion: string): Promise { const platformDir = getPlatformDir(); const release = await getRelease(runtimeVersion); const tag = release.tag_name; const assetName = `kandev-${platformDir}.tar.gz`; const cacheDir = path.join(resolveCacheDir(), 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); return tag; } async function prepareBundleForLaunch({ runtimeVersion, backendPort, verbose = false, debug = false, }: RunOptions): Promise { let bundleDir: string; let releaseTag: string; if (runtimeVersion) { // Explicit version: ensure it is in the cache (downloading if needed), then resolve. const platformDir = getPlatformDir(); const cached = findCachedRelease(platformDir, runtimeVersion); let tag: string; if (cached) { tag = cached.tag; bundleDir = findBundleRoot(cached.cacheDir); } else { try { tag = await downloadRuntimeVersion(runtimeVersion); const cacheDir = path.join(resolveCacheDir(), tag, platformDir); bundleDir = findBundleRoot(cacheDir); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw new Error( `Failed to fetch runtime version ${runtimeVersion}.\n` + ` Reason: ${reason}\n` + ` Run kandev once while online to cache a release for offline use.`, ); } } // Validate the resolved bundle has all required binaries before launching. validateBundle(bundleDir); releaseTag = tag; } else { // Default path: resolve from KANDEV_BUNDLE_DIR or installed npm runtime package. const resolved = resolveRuntime(); bundleDir = resolved.bundleDir; // Use KANDEV_VERSION if set (e.g. by Homebrew wrapper), otherwise show source. releaseTag = process.env.KANDEV_VERSION ?? `(${resolved.source})`; } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); const backendUrl = `http://localhost:${actualBackendPort}`; const levels = resolveBackendLogLevels({ verbose, debug }); const logLevel = levels.file; const dataDir = resolveDataDir(); fs.mkdirSync(dataDir, { recursive: true }); const dbPath = resolveDatabasePath(); const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); const backendEnv = buildBackendEnv({ ports: { backendPort: actualBackendPort, agentctlPort, backendUrl, }, logLevel, consoleLogLevel: levels.console, webProxy: false, extra: { KANDEV_DATABASE_PATH: dbPath, ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), }, }); return { bundleDir, backendBin, backendUrl, backendEnv, releaseTag, agentctlPort, dbPath, logLevel, }; } /** * 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 { const capture = createOutputRingBuffer(null, maxChars); capture.attach(stream); return capture.read; } async function launchBundle(prepared: PreparedBundle): Promise<{ supervisor: ReturnType; backendProc: ReturnType; dumpBackendLogs: () => void; }> { logStartupInfo({ header: `release: ${prepared.releaseTag}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), agentctlPort: prepared.agentctlPort, backendUrl: prepared.backendUrl, }, dbPath: prepared.dbPath, logLevel: prepared.logLevel, }); const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); const output = createOutputRingBuffer(process.stdout); const backend = await launchRestartableBackend({ command: prepared.backendBin, args: [], cwd: path.dirname(prepared.backendBin), env: prepared.backendEnv, homeDir: resolveKandevHomeDir(), ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), agentctlPort: prepared.agentctlPort, backendUrl: prepared.backendUrl, }, mode: "run", stdio: ["ignore", "pipe", "inherit"], supervisor, onProcess: (proc) => output.attach(proc.stdout), }); const backendProc = backend.proc; let dumped = false; const dumpBackendLogs = (): void => { if (dumped) return; dumped = true; const buffered = output.read(); if (buffered.trim().length === 0) return; console.error("[kandev] --- backend stdout (last captured output) ---"); console.error(buffered.trimEnd()); console.error("[kandev] --- end backend stdout ---"); }; return { supervisor, backendProc, dumpBackendLogs }; } export async function runRelease({ runtimeVersion, backendPort, verbose = false, debug = false, headless = false, }: RunOptions): Promise { const prepared = await prepareBundleForLaunch({ runtimeVersion, backendPort, verbose, debug, }); const { backendProc, dumpBackendLogs } = await launchBundle(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}`); if (headless) { console.log(`[kandev] ready (headless) at ${prepared.backendUrl}`); return; } console.log("[kandev] open: " + prepared.backendUrl); openBrowser(prepared.backendUrl); }