feat: cli launcher npx kandev (#39)

* feat: cli launcher

* feat: improved debug
This commit is contained in:
Carlos Florêncio
2026-01-18 20:42:03 +00:00
committed by GitHub
parent 16b3fb5b92
commit bc6a5f28d9
29 changed files with 1682 additions and 33 deletions
+37
View File
@@ -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;
}
+131
View File
@@ -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);
});
+19
View File
@@ -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");
+75
View File
@@ -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}`);
}
+189
View File
@@ -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;
}
+27
View File
@@ -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`);
}
+48
View File
@@ -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}`);
}
+81
View File
@@ -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`);
}
+34
View File
@@ -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 };
}
+152
View File
@@ -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}`);
}
+98
View File
@@ -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
}
}
+57
View File
@@ -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;
}