feat: cli launcher npx kandev (#39)
* feat: cli launcher * feat: improved debug
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function extractZip(zipPath: string, destDir: string): void {
|
||||
const zip = new AdmZip(zipPath);
|
||||
zip.extractAllTo(destDir, true);
|
||||
}
|
||||
|
||||
export function ensureExtracted(zipPath: string, destDir: string): void {
|
||||
const marker = path.join(destDir, ".extracted");
|
||||
if (fs.existsSync(marker)) {
|
||||
return;
|
||||
}
|
||||
extractZip(zipPath, destDir);
|
||||
fs.writeFileSync(marker, "");
|
||||
}
|
||||
|
||||
export function findBundleRoot(cacheDir: string): string {
|
||||
const candidate = path.join(cacheDir, "kandev");
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
export function resolveWebServerPath(bundleDir: string): string | null {
|
||||
const direct = path.join(bundleDir, "web", "server.js");
|
||||
if (fs.existsSync(direct)) {
|
||||
return direct;
|
||||
}
|
||||
const nested = path.join(bundleDir, "web", "apps", "web", "server.js");
|
||||
if (fs.existsSync(nested)) {
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import pkg from "../package.json";
|
||||
import { runDev } from "./dev";
|
||||
import { runRelease } from "./run";
|
||||
import { ensureValidPort } from "./ports";
|
||||
import { maybePromptForUpdate } from "./update";
|
||||
|
||||
type Command = "run" | "dev";
|
||||
|
||||
type CliOptions = {
|
||||
command: Command;
|
||||
version?: string;
|
||||
backendPort?: number;
|
||||
webPort?: number;
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`kandev launcher
|
||||
|
||||
Usage:
|
||||
kandev run [--version <tag>] [--backend-port <port>] [--web-port <port>]
|
||||
kandev dev [--backend-port <port>] [--web-port <port>]
|
||||
kandev [--version <tag>] [--backend-port <port>] [--web-port <port>]
|
||||
kandev --dev [--backend-port <port>] [--web-port <port>]
|
||||
|
||||
Examples:
|
||||
kandev
|
||||
kandev run
|
||||
kandev --dev
|
||||
kandev dev
|
||||
kandev --version v0.1.0
|
||||
kandev --backend-port 18080 --web-port 13000
|
||||
|
||||
Options:
|
||||
dev Use local repo for dev (make dev + next dev) if available.
|
||||
run Use release bundles (default).
|
||||
--dev Alias for "dev".
|
||||
--version Release tag to install (default: latest).
|
||||
--backend-port Override backend port.
|
||||
--web-port Override web port.
|
||||
--help, -h Show help.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = { command: "run" };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "dev" || arg === "run") {
|
||||
opts.command = arg;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--version") {
|
||||
opts.version = argv[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--version=")) {
|
||||
opts.version = arg.split("=")[1];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dev") {
|
||||
opts.command = "dev";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--backend-port") {
|
||||
opts.backendPort = Number(argv[i + 1]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--backend-port=")) {
|
||||
opts.backendPort = Number(arg.split("=")[1]);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--web-port") {
|
||||
opts.webPort = Number(argv[i + 1]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--web-port=")) {
|
||||
opts.webPort = Number(arg.split("=")[1]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function findRepoRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir);
|
||||
while (true) {
|
||||
const backendDir = path.join(current, "apps", "backend");
|
||||
const webDir = path.join(current, "apps", "web");
|
||||
if (fs.existsSync(backendDir) && fs.existsSync(webDir)) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const raw = parseArgs(process.argv.slice(2));
|
||||
const backendPort = ensureValidPort(raw.backendPort, "backend port");
|
||||
const webPort = ensureValidPort(raw.webPort, "web port");
|
||||
|
||||
if (raw.command === "dev") {
|
||||
const repoRoot = findRepoRoot(process.cwd());
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to locate repo root for dev. Run from the repo.");
|
||||
}
|
||||
await runDev({ repoRoot, backendPort, webPort });
|
||||
return;
|
||||
}
|
||||
|
||||
await maybePromptForUpdate(pkg.version, process.argv.slice(2));
|
||||
await runRelease({ version: raw.version, backendPort, webPort });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[kandev] ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Default service ports (will auto-fallback if busy).
|
||||
export const DEFAULT_BACKEND_PORT = 8080;
|
||||
export const DEFAULT_WEB_PORT = 3000;
|
||||
export const DEFAULT_AGENTCTL_PORT = 9999;
|
||||
|
||||
// Random fallback range for port selection.
|
||||
export const RANDOM_PORT_MIN = 10000;
|
||||
export const RANDOM_PORT_MAX = 60000;
|
||||
export const RANDOM_PORT_RETRIES = 10;
|
||||
|
||||
// Backend healthcheck timeout during startup.
|
||||
export const HEALTH_TIMEOUT_MS = 15000;
|
||||
|
||||
// Local user cache/data directories for release bundles and DB.
|
||||
export const CACHE_DIR = path.join(os.homedir(), ".kandev", "bin");
|
||||
export const DATA_DIR = path.join(os.homedir(), ".kandev", "data");
|
||||
@@ -0,0 +1,75 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENTCTL_PORT,
|
||||
DEFAULT_BACKEND_PORT,
|
||||
DEFAULT_WEB_PORT,
|
||||
HEALTH_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { waitForHealth } from "./health";
|
||||
import { pickAvailablePort } from "./ports";
|
||||
import { createProcessSupervisor } from "./process";
|
||||
import { launchWebApp } from "./web";
|
||||
|
||||
export type DevOptions = {
|
||||
repoRoot: string;
|
||||
backendPort?: number;
|
||||
webPort?: number;
|
||||
};
|
||||
|
||||
export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> {
|
||||
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 backendEnv = {
|
||||
...process.env,
|
||||
KANDEV_SERVER_PORT: String(actualBackendPort),
|
||||
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
||||
};
|
||||
const webEnv = {
|
||||
...process.env,
|
||||
KANDEV_API_BASE_URL: backendUrl,
|
||||
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
|
||||
PORT: String(actualWebPort),
|
||||
NEXT_PUBLIC_KANDEV_DEBUG: "true",
|
||||
};
|
||||
|
||||
console.log("[kandev] dev mode: using local repo");
|
||||
console.log("[kandev] backend port:", actualBackendPort);
|
||||
console.log("[kandev] web port:", actualWebPort);
|
||||
console.log("[kandev] agentctl port:", agentctlPort);
|
||||
|
||||
const supervisor = createProcessSupervisor();
|
||||
supervisor.attachSignalHandlers();
|
||||
|
||||
const backendProc = spawn(
|
||||
"make",
|
||||
["-C", path.join("apps", "backend"), "dev"],
|
||||
{ cwd: repoRoot, env: backendEnv, stdio: "inherit" },
|
||||
);
|
||||
supervisor.children.push(backendProc);
|
||||
|
||||
backendProc.on("exit", (code, signal) => {
|
||||
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
|
||||
const exitCode = signal ? 0 : code ?? 1;
|
||||
void supervisor.shutdown("backend exit").then(() => process.exit(exitCode));
|
||||
});
|
||||
|
||||
await waitForHealth(backendUrl, backendProc, HEALTH_TIMEOUT_MS);
|
||||
console.log(`[kandev] backend ready at ${backendUrl}`);
|
||||
|
||||
const webUrl = `http://localhost:${actualWebPort}`;
|
||||
launchWebApp({
|
||||
command: "pnpm",
|
||||
args: ["-C", "apps", "--filter", "@kandev/web", "dev"],
|
||||
cwd: repoRoot,
|
||||
env: webEnv,
|
||||
url: webUrl,
|
||||
supervisor,
|
||||
label: "web",
|
||||
});
|
||||
console.log(`[kandev] web ready at ${webUrl}`);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
|
||||
import { CACHE_DIR } from "./constants";
|
||||
|
||||
// Allow overriding the GitHub repo for forks/testing.
|
||||
const OWNER = process.env.KANDEV_GITHUB_OWNER || "kandev";
|
||||
const REPO = process.env.KANDEV_GITHUB_REPO || "kandev";
|
||||
const API_BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
|
||||
|
||||
type ReleaseAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
};
|
||||
|
||||
type ReleaseResponse = {
|
||||
tag_name?: string;
|
||||
assets?: ReleaseAsset[];
|
||||
};
|
||||
|
||||
function requestJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "kandev-npx",
|
||||
Accept: "application/vnd.github+json",
|
||||
...(process.env.KANDEV_GITHUB_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
||||
}
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body) as T);
|
||||
} catch {
|
||||
reject(new Error(`Failed to parse JSON from ${url}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy(new Error(`Request timed out fetching ${url}`));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
expectedSha256?: string | null,
|
||||
onProgress?: (downloaded: number, total: number) => void,
|
||||
): Promise<string> {
|
||||
const tempPath = `${destPath}.tmp`;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const file = fs.createWriteStream(tempPath);
|
||||
const hash = crypto.createHash("sha256");
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "kandev-npx",
|
||||
Accept: "application/octet-stream",
|
||||
...(process.env.KANDEV_GITHUB_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
file.close();
|
||||
cleanup();
|
||||
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
||||
}
|
||||
const totalSize = parseInt(res.headers["content-length"] || "0", 10);
|
||||
let downloadedSize = 0;
|
||||
res.on("data", (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
hash.update(chunk);
|
||||
if (onProgress) onProgress(downloadedSize, totalSize);
|
||||
});
|
||||
res.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
const actualSha256 = hash.digest("hex");
|
||||
if (expectedSha256 && actualSha256 !== expectedSha256) {
|
||||
cleanup();
|
||||
return reject(
|
||||
new Error(`Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`),
|
||||
);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(tempPath, destPath);
|
||||
resolve(destPath);
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy(new Error(`Request timed out downloading ${url}`));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
file.close();
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findAsset(release: ReleaseResponse, name: string): ReleaseAsset | undefined {
|
||||
return release.assets?.find((asset) => asset.name === name);
|
||||
}
|
||||
|
||||
function readSha256(pathToSha: string): string | null {
|
||||
if (!fs.existsSync(pathToSha)) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(pathToSha, "utf8").trim();
|
||||
const first = content.split(/\s+/)[0];
|
||||
return first || null;
|
||||
}
|
||||
|
||||
export async function getRelease(version?: string): Promise<ReleaseResponse> {
|
||||
if (version) {
|
||||
return requestJson<ReleaseResponse>(`${API_BASE}/releases/tags/${version}`);
|
||||
}
|
||||
return requestJson<ReleaseResponse>(`${API_BASE}/releases/latest`);
|
||||
}
|
||||
|
||||
export async function ensureAsset(
|
||||
release: ReleaseResponse,
|
||||
assetName: string,
|
||||
cacheDir: string,
|
||||
onProgress?: (downloaded: number, total: number) => void,
|
||||
): Promise<string> {
|
||||
const asset = findAsset(release, assetName);
|
||||
if (!asset) {
|
||||
throw new Error(`Release asset not found: ${assetName}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const destPath = path.join(cacheDir, assetName);
|
||||
const shaPath = `${destPath}.sha256`;
|
||||
|
||||
let expectedSha = readSha256(shaPath);
|
||||
if (!expectedSha) {
|
||||
const shaAsset = findAsset(release, `${assetName}.sha256`);
|
||||
if (shaAsset) {
|
||||
await downloadFile(shaAsset.browser_download_url, shaPath);
|
||||
expectedSha = readSha256(shaPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(destPath)) {
|
||||
if (!expectedSha) {
|
||||
return destPath;
|
||||
}
|
||||
const hash = crypto.createHash("sha256");
|
||||
const file = fs.readFileSync(destPath);
|
||||
hash.update(file);
|
||||
if (hash.digest("hex") === expectedSha) {
|
||||
return destPath;
|
||||
}
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
|
||||
await downloadFile(asset.browser_download_url, destPath, expectedSha, onProgress);
|
||||
return destPath;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function waitForHealth(
|
||||
baseUrl: string,
|
||||
proc: { exitCode: number | null },
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const healthUrl = `${baseUrl}/health`;
|
||||
while (Date.now() < deadline) {
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error("Backend exited before healthcheck passed");
|
||||
}
|
||||
try {
|
||||
const res = await fetch(healthUrl);
|
||||
if (res.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore until timeout
|
||||
}
|
||||
await delay(300);
|
||||
}
|
||||
throw new Error(`Backend healthcheck timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
export type PlatformArch = "x64" | "arm64";
|
||||
export type PlatformDir =
|
||||
| "linux-x64"
|
||||
| "macos-x64"
|
||||
| "macos-arm64"
|
||||
| "windows-x64"
|
||||
| "windows-arm64";
|
||||
|
||||
export function getBinaryName(base: string): string {
|
||||
return isWindows ? `${base}.exe` : base;
|
||||
}
|
||||
|
||||
function getEffectiveArch(): PlatformArch {
|
||||
const platform = process.platform;
|
||||
const nodeArch = process.arch;
|
||||
if (platform === "darwin") {
|
||||
if (nodeArch === "arm64") return "arm64";
|
||||
try {
|
||||
const translated = execSync("sysctl -in sysctl.proc_translated", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (translated === "1") return "arm64";
|
||||
} catch {}
|
||||
return "x64";
|
||||
}
|
||||
if (/arm/i.test(nodeArch)) return "arm64";
|
||||
if (platform === "win32") {
|
||||
const pa = process.env.PROCESSOR_ARCHITECTURE || "";
|
||||
const paw = process.env.PROCESSOR_ARCHITEW6432 || "";
|
||||
if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64";
|
||||
}
|
||||
return "x64";
|
||||
}
|
||||
|
||||
export function getPlatformDir(): PlatformDir {
|
||||
const platform = process.platform;
|
||||
const arch = getEffectiveArch();
|
||||
if (platform === "linux" && arch === "x64") return "linux-x64";
|
||||
if (platform === "darwin" && arch === "x64") return "macos-x64";
|
||||
if (platform === "darwin" && arch === "arm64") return "macos-arm64";
|
||||
if (platform === "win32" && arch === "x64") return "windows-x64";
|
||||
if (platform === "win32" && arch === "arm64") return "windows-arm64";
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import { RANDOM_PORT_MAX, RANDOM_PORT_MIN, RANDOM_PORT_RETRIES } from "./constants";
|
||||
|
||||
export function ensureValidPort(port: number | undefined, name: string): number | undefined {
|
||||
if (port === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`${name} must be an integer between 1 and 65535`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number, host = "0.0.0.0"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
server.listen(port, host, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function reserveSpecificPort(port: number, host = "127.0.0.1"): Promise<net.Server | null> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.on("error", () => resolve(null));
|
||||
server.listen(port, host, () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickAvailablePort(
|
||||
preferred: number,
|
||||
retries = RANDOM_PORT_RETRIES,
|
||||
): Promise<number> {
|
||||
if (await isPortAvailable(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
|
||||
if (await isPortAvailable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to find a free port after ${retries + 1} attempts`);
|
||||
}
|
||||
|
||||
export async function pickAndReservePort(
|
||||
preferred: number,
|
||||
retries = RANDOM_PORT_RETRIES,
|
||||
): Promise<{ port: number; release: () => Promise<void> }> {
|
||||
const reservedPreferred = await reserveSpecificPort(preferred);
|
||||
if (reservedPreferred) {
|
||||
return {
|
||||
port: preferred,
|
||||
release: () => new Promise((resolve) => reservedPreferred.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
|
||||
const reserved = await reserveSpecificPort(candidate);
|
||||
if (reserved) {
|
||||
return {
|
||||
port: candidate,
|
||||
release: () => new Promise((resolve) => reserved.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import kill from "tree-kill";
|
||||
|
||||
export type ChildLike = { pid?: number | undefined };
|
||||
|
||||
export function createProcessSupervisor(): {
|
||||
children: ChildLike[];
|
||||
shutdown: (reason: string) => Promise<void>;
|
||||
attachSignalHandlers: () => void;
|
||||
} {
|
||||
let shuttingDown = false;
|
||||
const children: ChildLike[] = [];
|
||||
|
||||
const shutdown = async (reason: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[kandev] shutting down (${reason})...`);
|
||||
await Promise.all(
|
||||
children
|
||||
.filter((child) => child.pid)
|
||||
.map((child) => new Promise<void>((resolve) => kill(child.pid as number, "SIGTERM", () => resolve()))),
|
||||
);
|
||||
};
|
||||
|
||||
const onSignal = (signal: NodeJS.Signals) => {
|
||||
void shutdown(`signal ${signal}`).then(() => process.exit(0));
|
||||
};
|
||||
|
||||
const attachSignalHandlers = () => {
|
||||
process.on("SIGINT", onSignal);
|
||||
process.on("SIGTERM", onSignal);
|
||||
};
|
||||
|
||||
return { children, shutdown, attachSignalHandlers };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle";
|
||||
import {
|
||||
DATA_DIR,
|
||||
DEFAULT_AGENTCTL_PORT,
|
||||
DEFAULT_BACKEND_PORT,
|
||||
DEFAULT_WEB_PORT,
|
||||
HEALTH_TIMEOUT_MS,
|
||||
} from "./constants";
|
||||
import { CACHE_DIR, ensureAsset, getRelease } from "./github";
|
||||
import { waitForHealth } from "./health";
|
||||
import { getBinaryName, getPlatformDir } from "./platform";
|
||||
import { pickAvailablePort } from "./ports";
|
||||
import { createProcessSupervisor } from "./process";
|
||||
import { launchWebApp } from "./web";
|
||||
|
||||
export type RunOptions = {
|
||||
version?: string;
|
||||
backendPort?: number;
|
||||
webPort?: number;
|
||||
};
|
||||
|
||||
type PreparedRelease = {
|
||||
bundleDir: string;
|
||||
backendBin: string;
|
||||
backendUrl: string;
|
||||
backendEnv: NodeJS.ProcessEnv;
|
||||
webEnv: NodeJS.ProcessEnv;
|
||||
webPort: number;
|
||||
agentctlPort: number;
|
||||
};
|
||||
|
||||
async function prepareReleaseBundle({
|
||||
version,
|
||||
backendPort,
|
||||
webPort,
|
||||
}: RunOptions): Promise<PreparedRelease> {
|
||||
const platformDir = getPlatformDir();
|
||||
const release = await getRelease(version);
|
||||
const tag = release.tag_name || "latest";
|
||||
const assetName = `kandev-${platformDir}.zip`;
|
||||
const cacheDir = path.join(CACHE_DIR, tag, platformDir);
|
||||
|
||||
const zipPath = await ensureAsset(release, 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(zipPath, cacheDir);
|
||||
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}`;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const dbPath = path.join(DATA_DIR, "kandev.db");
|
||||
|
||||
const backendEnv = {
|
||||
...process.env,
|
||||
KANDEV_SERVER_PORT: String(actualBackendPort),
|
||||
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
||||
KANDEV_DB_PATH: dbPath,
|
||||
};
|
||||
|
||||
const webEnv = {
|
||||
...process.env,
|
||||
KANDEV_API_BASE_URL: backendUrl,
|
||||
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
|
||||
PORT: String(actualWebPort),
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
return {
|
||||
bundleDir,
|
||||
backendBin,
|
||||
backendUrl,
|
||||
backendEnv,
|
||||
webEnv,
|
||||
webPort: actualWebPort,
|
||||
agentctlPort,
|
||||
};
|
||||
}
|
||||
|
||||
function launchReleaseApps(prepared: PreparedRelease): {
|
||||
supervisor: ReturnType<typeof createProcessSupervisor>;
|
||||
backendProc: ReturnType<typeof spawn>;
|
||||
webServerPath: string;
|
||||
} {
|
||||
console.log("[kandev] backend port:", prepared.backendEnv.KANDEV_SERVER_PORT);
|
||||
console.log("[kandev] web port:", prepared.webPort);
|
||||
console.log("[kandev] agentctl port:", prepared.agentctlPort);
|
||||
|
||||
const supervisor = createProcessSupervisor();
|
||||
supervisor.attachSignalHandlers();
|
||||
|
||||
const backendProc = spawn(prepared.backendBin, [], {
|
||||
cwd: path.dirname(prepared.backendBin),
|
||||
env: prepared.backendEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
supervisor.children.push(backendProc);
|
||||
|
||||
backendProc.on("exit", (code, signal) => {
|
||||
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
|
||||
void supervisor.shutdown("backend exit").then(() => process.exit(code || 1));
|
||||
});
|
||||
|
||||
const webServerPath = resolveWebServerPath(prepared.bundleDir);
|
||||
if (!webServerPath) {
|
||||
throw new Error("Web server entry (server.js) not found in bundle");
|
||||
}
|
||||
|
||||
const webUrl = `http://localhost:${prepared.webPort}`;
|
||||
const webProc = launchWebApp({
|
||||
command: "node",
|
||||
args: [webServerPath],
|
||||
cwd: path.dirname(webServerPath),
|
||||
env: prepared.webEnv,
|
||||
url: webUrl,
|
||||
supervisor,
|
||||
label: "web",
|
||||
});
|
||||
|
||||
return { supervisor, backendProc, webServerPath, webProc };
|
||||
}
|
||||
|
||||
export async function runRelease({ version, backendPort, webPort }: RunOptions) {
|
||||
const prepared = await prepareReleaseBundle({ version, backendPort, webPort });
|
||||
const { backendProc } = launchReleaseApps(prepared);
|
||||
// Wait for backend before announcing the web URL.
|
||||
await waitForHealth(prepared.backendUrl, backendProc, HEALTH_TIMEOUT_MS);
|
||||
console.log(`[kandev] backend ready at ${prepared.backendUrl}`);
|
||||
console.log(`[kandev] web ready at http://localhost:${prepared.webPort}`);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import https from "node:https";
|
||||
import readline from "node:readline";
|
||||
|
||||
function requestJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{ headers: { "User-Agent": "kandev-npx" } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
||||
}
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body) as T);
|
||||
} catch {
|
||||
reject(new Error(`Failed to parse JSON from ${url}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy(new Error(`Request timed out fetching ${url}`));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getLatestNpmVersion(): Promise<string | undefined> {
|
||||
const data = await requestJson<{ "dist-tags"?: { latest?: string } }>(
|
||||
"https://registry.npmjs.org/kandev",
|
||||
);
|
||||
return data?.["dist-tags"]?.latest;
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = String(a).replace(/^v/, "").split(".").map(Number);
|
||||
const pb = String(b).replace(/^v/, "").split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
||||
const av = pa[i] ?? 0;
|
||||
const bv = pb[i] ?? 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (!process.stdin.isTTY) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
rl.question(`${question} ${suffix} `, (answer: string) => {
|
||||
rl.close();
|
||||
const normalized = String(answer || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
resolve(Boolean(defaultYes));
|
||||
return;
|
||||
}
|
||||
resolve(normalized === "y" || normalized === "yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function maybePromptForUpdate(currentVersion: string, args: string[]) {
|
||||
// Allow disabling update checks for CI or automation.
|
||||
if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const latest = await getLatestNpmVersion();
|
||||
if (!latest) return;
|
||||
if (compareVersions(latest, currentVersion) <= 0) return;
|
||||
const wantsUpdate = await promptYesNo(
|
||||
`Update available: ${currentVersion} -> ${latest}. Update now?`,
|
||||
false,
|
||||
);
|
||||
if (!wantsUpdate) return;
|
||||
const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" };
|
||||
const child = spawn("npx", ["kandev@latest", ...args], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
child.on("exit", (code) => process.exit(code || 0));
|
||||
await new Promise(() => {});
|
||||
} catch {
|
||||
// ignore update errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { createProcessSupervisor } from "./process";
|
||||
|
||||
export type WebLaunchOptions = {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
url: string;
|
||||
supervisor: ReturnType<typeof createProcessSupervisor>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function openBrowser(url: string) {
|
||||
if (process.env.KANDEV_NO_BROWSER === "1") {
|
||||
return;
|
||||
}
|
||||
const opener =
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
: process.platform === "win32"
|
||||
? "cmd"
|
||||
: "xdg-open";
|
||||
const args =
|
||||
process.platform === "win32"
|
||||
? ["/c", "start", "", url]
|
||||
: [url];
|
||||
try {
|
||||
const child = spawn(opener, args, { stdio: "ignore", detached: true });
|
||||
child.unref();
|
||||
} catch {
|
||||
// ignore browser launch errors
|
||||
}
|
||||
}
|
||||
|
||||
export function launchWebApp({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
url,
|
||||
supervisor,
|
||||
label,
|
||||
}: WebLaunchOptions) {
|
||||
const proc = spawn(command, args, { cwd, env, stdio: "inherit" });
|
||||
supervisor.children.push(proc);
|
||||
|
||||
proc.on("exit", (code, signal) => {
|
||||
console.error(`[kandev] ${label} exited (code=${code}, signal=${signal})`);
|
||||
const exitCode = signal ? 0 : code ?? 1;
|
||||
void supervisor.shutdown(`${label} exit`).then(() => process.exit(exitCode));
|
||||
});
|
||||
|
||||
openBrowser(url);
|
||||
return proc;
|
||||
}
|
||||
Reference in New Issue
Block a user