47ff0804a5
* feat(cli): add --port flag as the user-facing port knob
Introduces --port (and KANDEV_PORT) as the primary, command-aware port
flag: in start/run it sets the Go backend port (the proxy front door
the browser actually hits), and in dev it sets the Next dev server port.
--backend-port and --web-port still work but emit a one-time stderr
deprecation note pointing at --port and --web-internal-port respectively.
* feat(cli): print labelled "open:" URL on startup
Replaces the ambiguous "[kandev] url:" / "ready at" lines with a
labelled block that always shows the user-facing URL first, plus the
backend (api) and web (internal) URLs when they differ. Each command
now declares its own publicUrl: backend in start/run (where the Go
server reverse-proxies Next.js), web in dev (browser hits Next direct).
* style(cli): apply prettier formatting to args.test.ts
* refactor(cli): tighten arg parsing per review feedback
- Validate value-taking flags (--port, --backend-port, --web-internal-port,
--web-port, --version) and reject missing/non-numeric values with a clear
ParseError instead of leaking NaN into the port validator.
- Drop the test-only _resetWarnedFlagsForTest export and the module-level
warned-flag set; parseArgs now returns deduped deprecatedFlags and cli.ts
emits warnings command-aware (e.g. --backend-port → KANDEV_BACKEND_PORT
in dev, where --port maps to web).
- Stop double-printing the "open:" URL: logStartupInfo now lists labelled
backend (api) / web (internal) URLs only, leaving the post-readiness
open: line as the single source of truth.
- Fix help text: --web-internal-port works in dev too.
- Drop multi-line JSDoc blocks per CLAUDE.md style guide.
* fix(cli): validate env-var ports and collapse comment to one-liner
Catches non-numeric KANDEV_PORT / KANDEV_BACKEND_PORT / KANDEV_WEB_PORT in
envPort with a clear ParseError instead of letting NaN reach
ensureValidPort. Drops a multi-line comment per CLAUDE.md style.
* fix(cli): reject non-integer port values
Number.isFinite accepts floats like 3000.5 which then fail downstream
in ensureValidPort with a generic message. Tighten parsePort and
envPort to require integers and add float tests for both flag and
env-var paths.
* fix(cli): reject out-of-range port values with flag-named errors
Adds 1-65535 range checks to parsePort and envPort so values like 0,
-1, or 65536 fail with a flag/env-var-named ParseError instead of
reaching ensureValidPort with a generic 'backend port' label.
* test(cli): cover KANDEV_WEB_PORT happy path and range validation
* test(cli): cover KANDEV_WEB_PORT in run/start mode too
* fix(cli): explicit CLI flag wins over env var for port resolution
Previously KANDEV_BACKEND_PORT silently overrode --port because each
process port resolved its own env var before the generic --port
fallback. New precedence per process: explicit CLI flag > generic
--port flag > explicit env var > generic KANDEV_PORT. Also relabel
the startup info from "backend (api)" / "web (internal)" to plain
"backend" / "web" — "internal" was misleading in dev where the web
URL is what users open.
* style(cli): collapse resolvePorts comment to one line
* fix(cli): reject empty --version= and empty port env vars
- --version= (no value after =) now throws ParseError instead of
silently storing the empty string as the version tag.
- envPort distinguishes unset (undefined) from empty (""); an empty
KANDEV_PORT / KANDEV_BACKEND_PORT / KANDEV_WEB_PORT is now treated
as misconfiguration and fails fast.
* refactor(cli): make --port a plain alias for --backend-port
Drops the command-aware split where --port mapped to web in dev and
backend in run/start. --port and --backend-port now both set
options.backendPort regardless of command, and KANDEV_PORT is a plain
fallback for KANDEV_BACKEND_PORT. The whole point of the original
fix was that the *backend port* is the URL users actually open in
run/start (because the Go server reverse-proxies Next.js); a single
flag that always means "the backend port" matches that mental model
without per-command surprises.
Removes deprecationReplacement and the command-aware warning logic
since the two flags are now interchangeable. --web-port → --web-internal-port
deprecation stays.
* test(cli): re-add KANDEV_WEB_PORT run-mode coverage dropped in refactor
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle";
|
|
import {
|
|
CACHE_DIR,
|
|
DATA_DIR,
|
|
DEFAULT_AGENTCTL_PORT,
|
|
DEFAULT_BACKEND_PORT,
|
|
DEFAULT_WEB_PORT,
|
|
HEALTH_TIMEOUT_MS_RELEASE,
|
|
} from "./constants";
|
|
import { ensureAsset, getRelease } from "./github";
|
|
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
|
|
import { getBinaryName, getPlatformDir } from "./platform";
|
|
import { sortVersionsDesc } from "./version";
|
|
import { pickAvailablePort } from "./ports";
|
|
import { createProcessSupervisor } from "./process";
|
|
import { attachBackendExitHandler, logStartupInfo } from "./shared";
|
|
import { launchWebApp, openBrowser } from "./web";
|
|
|
|
export type RunOptions = {
|
|
version?: string;
|
|
backendPort?: number;
|
|
webPort?: number;
|
|
/** Show info logs from backend + web */
|
|
verbose?: boolean;
|
|
/** Show debug logs + agent message dumps */
|
|
debug?: boolean;
|
|
};
|
|
|
|
type PreparedRelease = {
|
|
bundleDir: string;
|
|
backendBin: string;
|
|
backendUrl: string;
|
|
backendEnv: NodeJS.ProcessEnv;
|
|
webEnv: NodeJS.ProcessEnv;
|
|
releaseTag: string;
|
|
requestedVersion?: string;
|
|
webPort: number;
|
|
agentctlPort: number;
|
|
dbPath: string;
|
|
logLevel: string;
|
|
showOutput: boolean;
|
|
};
|
|
|
|
/**
|
|
* 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 {
|
|
if (version) {
|
|
const cacheDir = path.join(CACHE_DIR, 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(CACHE_DIR)) return null;
|
|
|
|
const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v"));
|
|
if (entries.length === 0) return null;
|
|
|
|
const sorted = sortVersionsDesc(entries);
|
|
|
|
for (const tag of sorted) {
|
|
const cacheDir = path.join(CACHE_DIR, 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) {
|
|
try {
|
|
if (!fs.existsSync(CACHE_DIR)) return;
|
|
const entries = fs.readdirSync(CACHE_DIR).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(CACHE_DIR, entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical — don't fail the launch if cleanup errors.
|
|
}
|
|
}
|
|
|
|
async function prepareReleaseBundle({
|
|
version,
|
|
backendPort,
|
|
webPort,
|
|
verbose = false,
|
|
debug = false,
|
|
}: RunOptions): Promise<PreparedRelease> {
|
|
const platformDir = getPlatformDir();
|
|
let tag: string;
|
|
let cacheDir: string;
|
|
|
|
try {
|
|
const release = await getRelease(version);
|
|
tag = release.tag_name;
|
|
const assetName = `kandev-${platformDir}.tar.gz`;
|
|
cacheDir = path.join(CACHE_DIR, 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);
|
|
} catch (err) {
|
|
// GitHub unreachable — try to launch from cache.
|
|
const cached = findCachedRelease(platformDir, version);
|
|
if (!cached) {
|
|
const target = version ? `version ${version}` : "latest version";
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
throw new Error(
|
|
`Failed to fetch ${target} and no cached release found.\n` +
|
|
` Reason: ${reason}\n` +
|
|
` Run kandev once while online to cache a release for offline use.`,
|
|
);
|
|
}
|
|
tag = cached.tag;
|
|
cacheDir = cached.cacheDir;
|
|
process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`);
|
|
}
|
|
|
|
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}`;
|
|
const showOutput = verbose || debug;
|
|
const logLevel =
|
|
process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
|
|
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const dbPath = path.join(DATA_DIR, "kandev.db");
|
|
|
|
// Note: Release mode doesn't configure MCP server ports as it uses
|
|
// the bundled configuration. Only backend and agentctl ports are set.
|
|
// Log level defaults to warn for clean output and can be overridden
|
|
// via KANDEV_LOG_LEVEL or --verbose/--debug flags.
|
|
const backendEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
KANDEV_SERVER_PORT: String(actualBackendPort),
|
|
KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
|
|
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
|
KANDEV_DATABASE_PATH: dbPath,
|
|
KANDEV_LOG_LEVEL: logLevel,
|
|
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}),
|
|
};
|
|
|
|
const webEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
KANDEV_API_BASE_URL: backendUrl,
|
|
PORT: String(actualWebPort),
|
|
// Ensure Next.js standalone server binds to 127.0.0.1 so localhost health checks work.
|
|
// Without this, HOSTNAME from the host environment can cause binding issues.
|
|
HOSTNAME: "127.0.0.1",
|
|
};
|
|
(webEnv as Record<string, string>).NODE_ENV = "production";
|
|
|
|
return {
|
|
bundleDir,
|
|
backendBin,
|
|
backendUrl,
|
|
backendEnv,
|
|
webEnv,
|
|
releaseTag: tag,
|
|
requestedVersion: version,
|
|
webPort: actualWebPort,
|
|
agentctlPort,
|
|
dbPath,
|
|
logLevel,
|
|
showOutput,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
let buf = "";
|
|
stream?.on("data", (chunk: Buffer | string) => {
|
|
buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
if (buf.length > maxChars) {
|
|
buf = buf.slice(buf.length - maxChars);
|
|
}
|
|
});
|
|
return () => buf;
|
|
}
|
|
|
|
function launchReleaseApps(prepared: PreparedRelease): {
|
|
supervisor: ReturnType<typeof createProcessSupervisor>;
|
|
backendProc: ReturnType<typeof spawn>;
|
|
webServerPath: string;
|
|
dumpBackendLogs: () => void;
|
|
} {
|
|
const releaseSource = prepared.requestedVersion
|
|
? `(requested: ${prepared.requestedVersion})`
|
|
: "(github latest)";
|
|
logStartupInfo({
|
|
header: `release: ${prepared.releaseTag} ${releaseSource}`,
|
|
ports: {
|
|
backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT),
|
|
webPort: prepared.webPort,
|
|
agentctlPort: prepared.agentctlPort,
|
|
backendUrl: prepared.backendUrl,
|
|
},
|
|
dbPath: prepared.dbPath,
|
|
logLevel: prepared.logLevel,
|
|
});
|
|
|
|
const supervisor = createProcessSupervisor();
|
|
supervisor.attachSignalHandlers();
|
|
|
|
// Start backend: ignore stdin, always show stderr immediately.
|
|
// In verbose/debug mode stream stdout live; otherwise capture it into a ring
|
|
// buffer so we can dump the last few KB if the healthcheck fails (users were
|
|
// previously seeing opaque "timed out" errors with no backend context).
|
|
const backendProc = spawn(prepared.backendBin, [], {
|
|
cwd: path.dirname(prepared.backendBin),
|
|
env: prepared.backendEnv,
|
|
stdio: prepared.showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "inherit"],
|
|
});
|
|
supervisor.children.push(backendProc);
|
|
|
|
const readBuffered = prepared.showOutput ? () => "" : attachRingBuffer(backendProc.stdout);
|
|
let dumped = false;
|
|
const dumpBackendLogs = (): void => {
|
|
if (dumped) return;
|
|
dumped = true;
|
|
const buffered = readBuffered();
|
|
if (buffered.trim().length === 0) return;
|
|
console.error("[kandev] --- backend stdout (last captured output) ---");
|
|
console.error(buffered.trimEnd());
|
|
console.error("[kandev] --- end backend stdout ---");
|
|
};
|
|
|
|
attachBackendExitHandler(backendProc, supervisor);
|
|
|
|
const webServerPath = resolveWebServerPath(prepared.bundleDir);
|
|
if (!webServerPath) {
|
|
throw new Error("Web server entry (server.js) not found in bundle");
|
|
}
|
|
|
|
return { supervisor, backendProc, webServerPath, dumpBackendLogs };
|
|
}
|
|
|
|
export async function runRelease({
|
|
version,
|
|
backendPort,
|
|
webPort,
|
|
verbose = false,
|
|
debug = false,
|
|
}: RunOptions): Promise<void> {
|
|
const prepared = await prepareReleaseBundle({ version, backendPort, webPort, verbose, debug });
|
|
const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchReleaseApps(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}`);
|
|
|
|
const webUrl = `http://localhost:${prepared.webPort}`;
|
|
console.log("[kandev] starting web...");
|
|
const webProc = launchWebApp({
|
|
command: "node",
|
|
args: [webServerPath],
|
|
cwd: path.dirname(webServerPath),
|
|
env: prepared.webEnv,
|
|
supervisor,
|
|
label: "web",
|
|
quiet: !prepared.showOutput,
|
|
});
|
|
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
|
console.log("[kandev] open: " + prepared.backendUrl);
|
|
openBrowser(prepared.backendUrl);
|
|
}
|