import fs from "node:fs"; import path from "node:path"; import { backupProductionDb, isProductionDb } from "./backup"; import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants"; import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health"; import { isInsideKandevTask } from "./kandev-env"; import { createProcessSupervisor } from "./process"; import { buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared"; import { launchRestartableBackend } from "./supervisor/backend"; import { launchWebApp, openBrowser } from "./web"; export type DevOptions = { repoRoot: string; backendPort?: number; webPort?: number; }; export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise { const ports = await pickPorts(backendPort, webPort); const { dbPath, extra } = resolveDevBackendEnv(repoRoot); if (isProductionDb(dbPath)) { try { const backupPath = backupProductionDb(dbPath); if (backupPath) { const name = path.basename(backupPath); console.log(`[kandev] backed up production db → ${name}`); } } catch (err) { const message = err instanceof Error ? err.message : String(err); // Abort rather than continue: the backup exists precisely to protect the // production db before dev mode touches it. Proceeding on failure would // remove the safety guarantee that justified introducing this guard. throw new Error(`failed to back up production db (${message}); aborting dev startup`); } } const backendEnv = buildBackendEnv({ ports, extra }); const webEnv = buildWebEnv({ ports, debug: true }); const logLevel = process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; const webUrl = `http://localhost:${ports.webPort}`; const appUrl = ports.backendUrl; logStartupInfo({ header: "dev mode: using local repo", ports, dbPath, logLevel, }); const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); const { cmd: backendCmd, args: backendArgs } = withWinjobWrap(repoRoot, "make", [ "-C", path.join("apps", "backend"), "dev", ]); const backend = await launchRestartableBackend({ command: backendCmd, args: backendArgs, cwd: repoRoot, env: backendEnv, homeDir: backendEnv.KANDEV_HOME_DIR ?? devKandevHome(repoRoot), ports, mode: "dev", stdio: "inherit", supervisor, }); const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_DEV); console.log("[kandev] starting backend..."); await waitForHealth(ports.backendUrl, backend.proc, healthTimeoutMs); console.log(`[kandev] backend ready at ${ports.backendUrl}`); console.log("[kandev] starting web..."); const webProc = launchWebApp({ command: "pnpm", args: ["-C", "apps", "--filter", "@kandev/web", "dev"], cwd: repoRoot, env: webEnv, supervisor, label: "web", }); await waitForUrlReady(webUrl, webProc, healthTimeoutMs); console.log(`[kandev] open: ${appUrl}`); openBrowser(appUrl); } type DevBackendEnv = { dbPath: string; extra: Record; }; // Computes the dev-mode backend env. Dev mode always roots kandev under // /.kandev-dev so state is isolated from the user's production ~/.kandev // and so `make clean-db` (which removes .kandev-dev/) matches what `make dev` // writes. // // When invoked from inside a kandev task workspace, any KANDEV_DATABASE_PATH // is assumed to be leaked from the parent backend and is ignored. In a normal // shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch. export function resolveDevBackendEnv(repoRoot: string): DevBackendEnv { // Profile-selector only: the backend reads profiles.yaml at startup // and applies the matching `dev:` values (mock agent, pprof, // feature flags, etc.) to its own env. We don't repeat those here — // profiles.yaml at the repo root is the single source of truth. // See docs/decisions/0007-runtime-feature-flags.md. const baseExtra: Record = { KANDEV_DEBUG_DEV_MODE: "true", }; const devHome = devKandevHome(repoRoot); // Display only; the backend derives its own DB path from KANDEV_HOME_DIR // via ResolvedDataDir(). Both resolve to the same location. const devDbPath = path.join(devHome, "data", "kandev.db"); if (isInsideKandevTask(repoRoot)) { console.log("[kandev] task workspace detected → using local dev state"); return { dbPath: devDbPath, extra: { ...baseExtra, KANDEV_HOME_DIR: devHome, // Clear a parent-leaked DB path so the backend uses the HomeDir-derived default. KANDEV_DATABASE_PATH: "", }, }; } const override = process.env.KANDEV_DATABASE_PATH; if (override) { return { dbPath: override, extra: { ...baseExtra, KANDEV_DATABASE_PATH: override }, }; } return { dbPath: devDbPath, extra: { ...baseExtra, KANDEV_HOME_DIR: devHome, KANDEV_DATABASE_PATH: "", }, }; } // withWinjobWrap on Windows prepends apps/backend/bin/winjob.exe to a spawn // command so the child runs inside a Job Object configured with // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. That makes "kill the whole backend // subtree" a kernel-level guarantee tied to winjob's process exit, instead of // relying on the bash → make → pnpm → node → make → sh → kandev signal chain // (which drops Ctrl-C at multiple links because MSYS bash, native Win32 // processes, and Node disagree on signal propagation semantics). // // On Unix this is a passthrough — POSIX process groups already give us // reliable cascading termination. // // If the winjob binary isn't built yet (the user ran `make dev` before // `make -C apps/backend build-winjob`), we fall back to a direct spawn and // emit a one-line note. The supervisor's tree-kill still handles the happy // path; users only notice the gap if Ctrl-C drops mid-chain. function withWinjobWrap( repoRoot: string, cmd: string, args: string[], ): { cmd: string; args: string[] } { if (process.platform !== "win32") return { cmd, args }; const winjob = path.join(repoRoot, "apps", "backend", "bin", "winjob.exe"); if (!fs.existsSync(winjob)) { console.warn( `[kandev] winjob.exe not built — Ctrl-C may leak processes on Windows. ` + `Run \`make -C apps/backend build-winjob\` once to enable.`, ); return { cmd, args }; } return { cmd: winjob, args: [cmd, ...args] }; }