Files
kandev/apps/cli/src/start.ts
T
José Almeida 47ff0804a5 feat(cli): make --port the user-facing port flag (#737)
* 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
2026-04-29 22:25:10 +01:00

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);
}