316 lines
9.9 KiB
TypeScript
316 lines
9.9 KiB
TypeScript
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<string>([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<string> {
|
|
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<PreparedBundle> {
|
|
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<typeof createProcessSupervisor>;
|
|
backendProc: ReturnType<typeof spawn>;
|
|
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<void> {
|
|
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);
|
|
}
|