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
213 lines
7.7 KiB
TypeScript
213 lines
7.7 KiB
TypeScript
/**
|
|
* Production start command for running local builds.
|
|
*
|
|
* This module implements the `kandev start` command, which runs the locally
|
|
* built backend binary and web app in production mode. Unlike `kandev dev`
|
|
* which uses hot-reloading, this runs the optimized production builds.
|
|
*
|
|
* Prerequisites:
|
|
* - Backend must be built: `make build-backend`
|
|
* - Web app must be built: `make build-web`
|
|
* - Or simply: `make build` (builds both)
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { DATA_DIR, HEALTH_TIMEOUT_MS_RELEASE } from "./constants";
|
|
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
|
|
import { getBinaryName } from "./platform";
|
|
import { createProcessSupervisor } from "./process";
|
|
import {
|
|
attachBackendExitHandler,
|
|
buildBackendEnv,
|
|
buildWebEnv,
|
|
logStartupInfo,
|
|
pickPorts,
|
|
} from "./shared";
|
|
import { launchWebApp, openBrowser } from "./web";
|
|
|
|
/**
|
|
* Locates the standalone Next.js `server.js` inside `apps/web/.next/standalone/`.
|
|
*
|
|
* Normally this sits at `.next/standalone/web/server.js`, but Turbopack may
|
|
* place it under a deeper path (e.g. `.next/standalone/Users/.../web/server.js`)
|
|
* when it detects the wrong project root (typically caused by a stray
|
|
* `package-lock.json` in a parent directory). We look for any `web/server.js`
|
|
* below the standalone directory so `kandev start` keeps working.
|
|
*
|
|
* @returns absolute path to `server.js`, or null if it cannot be found.
|
|
*/
|
|
export function resolveStandaloneServerPath(repoRoot: string): string | null {
|
|
const standaloneDir = path.join(repoRoot, "apps", "web", ".next", "standalone");
|
|
const expected = path.join(standaloneDir, "web", "server.js");
|
|
if (fs.existsSync(expected)) return expected;
|
|
if (!fs.existsSync(standaloneDir)) return null;
|
|
|
|
return findWebServerJs(standaloneDir);
|
|
}
|
|
|
|
// Walks `dir` manually instead of relying on `Dirent.parentPath` (Node 20.12+),
|
|
// so the CLI keeps working on older Node 20.x runtimes installed via npx.
|
|
function findWebServerJs(dir: string): string | null {
|
|
const stack: string[] = [dir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop() as string;
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
stack.push(path.join(current, entry.name));
|
|
} else if (entry.isFile() && entry.name === "server.js" && path.basename(current) === "web") {
|
|
return path.join(current, entry.name);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export type StartOptions = {
|
|
/** Path to the repository root directory */
|
|
repoRoot: string;
|
|
/** Optional preferred backend port (finds available port if not specified) */
|
|
backendPort?: number;
|
|
/** Optional preferred web port (finds available port if not specified) */
|
|
webPort?: number;
|
|
/** Show info logs from backend + web */
|
|
verbose?: boolean;
|
|
/** Show debug logs + agent message dumps */
|
|
debug?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Runs the application in production mode using local builds.
|
|
*
|
|
* This function:
|
|
* 1. Validates that build artifacts exist
|
|
* 2. Picks available ports for all services
|
|
* 3. Starts the backend binary (with warn log level for clean output)
|
|
* 4. Starts the web app via `pnpm start`
|
|
* 5. Waits for the backend to be healthy before announcing readiness
|
|
*
|
|
* @param options - Configuration for the start command
|
|
* @throws Error if backend binary or web build is not found
|
|
*/
|
|
export async function runStart({
|
|
repoRoot,
|
|
backendPort,
|
|
webPort,
|
|
verbose = false,
|
|
debug = false,
|
|
}: StartOptions): Promise<void> {
|
|
const ports = await pickPorts(backendPort, webPort);
|
|
|
|
const backendBin = path.join(repoRoot, "apps", "backend", "bin", getBinaryName("kandev"));
|
|
if (!fs.existsSync(backendBin)) {
|
|
throw new Error("Backend binary not found. Run `make build` first.");
|
|
}
|
|
|
|
// Check for standalone build (Next.js standalone output)
|
|
const webServerPath = resolveStandaloneServerPath(repoRoot);
|
|
if (!webServerPath) {
|
|
const standaloneDir = path.join(repoRoot, "apps", "web", ".next", "standalone");
|
|
if (!fs.existsSync(standaloneDir)) {
|
|
throw new Error("Web standalone build not found. Run `make build` first.");
|
|
}
|
|
throw new Error(
|
|
`Web standalone build is missing server.js under ${standaloneDir}. ` +
|
|
"This can happen when Next.js/Turbopack detects a different project root " +
|
|
"(e.g. a stray package-lock.json in a parent directory). " +
|
|
"Remove the stray lockfile and re-run `make build`.",
|
|
);
|
|
}
|
|
const webStandaloneDir = path.dirname(webServerPath);
|
|
const webStaticDir = path.join(repoRoot, "apps", "web", ".next", "static");
|
|
const standaloneStaticDir = path.join(webStandaloneDir, ".next", "static");
|
|
if (fs.existsSync(webStaticDir) && !fs.existsSync(standaloneStaticDir)) {
|
|
fs.mkdirSync(path.dirname(standaloneStaticDir), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(webStaticDir, standaloneStaticDir, "junction");
|
|
} catch (err) {
|
|
console.warn(
|
|
`[kandev] failed to link Next.js static assets: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Link public directory (fonts, images, etc.) into standalone output
|
|
const webPublicDir = path.join(repoRoot, "apps", "web", "public");
|
|
const standalonePublicDir = path.join(webStandaloneDir, "public");
|
|
if (fs.existsSync(webPublicDir) && !fs.existsSync(standalonePublicDir)) {
|
|
try {
|
|
fs.symlinkSync(webPublicDir, standalonePublicDir, "junction");
|
|
} catch (err) {
|
|
console.warn(
|
|
`[kandev] failed to link public assets: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Production mode: use warn log level for clean output unless verbose/debug
|
|
const showOutput = verbose || debug;
|
|
const dbPath = process.env.KANDEV_DATABASE_PATH || path.join(DATA_DIR, "kandev.db");
|
|
const logLevel =
|
|
process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
|
|
const backendEnv = buildBackendEnv({
|
|
ports,
|
|
logLevel,
|
|
extra: {
|
|
KANDEV_DATABASE_PATH: dbPath,
|
|
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}),
|
|
},
|
|
});
|
|
const webEnv = buildWebEnv({ ports, production: true, debug });
|
|
|
|
logStartupInfo({
|
|
header: "start mode: using local build",
|
|
ports,
|
|
dbPath,
|
|
logLevel,
|
|
});
|
|
|
|
const supervisor = createProcessSupervisor();
|
|
supervisor.attachSignalHandlers();
|
|
|
|
// Start backend: ignore stdin, show stdout only in verbose/debug mode, always show stderr
|
|
// Stderr is always inherited to ensure error messages are visible immediately (no pipe buffering)
|
|
const backendProc = spawn(backendBin, [], {
|
|
cwd: path.dirname(backendBin),
|
|
env: backendEnv,
|
|
stdio: showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "ignore", "inherit"],
|
|
});
|
|
supervisor.children.push(backendProc);
|
|
|
|
attachBackendExitHandler(backendProc, supervisor);
|
|
|
|
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE);
|
|
console.log("[kandev] starting backend...");
|
|
await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs);
|
|
console.log(`[kandev] backend ready at ${ports.backendUrl}`);
|
|
|
|
// Use standalone server.js directly (not pnpm start)
|
|
const webUrl = `http://localhost:${ports.webPort}`;
|
|
console.log("[kandev] starting web...");
|
|
const webProc = launchWebApp({
|
|
command: "node",
|
|
args: [webServerPath],
|
|
cwd: webStandaloneDir,
|
|
env: webEnv,
|
|
supervisor,
|
|
label: "web",
|
|
quiet: !showOutput,
|
|
});
|
|
|
|
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
|
console.log("[kandev] open: " + ports.backendUrl);
|
|
openBrowser(ports.backendUrl);
|
|
}
|