diff --git a/cli/commands/dev/command-help.ts b/cli/commands/dev/command-help.ts index bd225c37df..7a84cc550f 100644 --- a/cli/commands/dev/command-help.ts +++ b/cli/commands/dev/command-help.ts @@ -8,7 +8,7 @@ export const devHelp: CommandHelp = { options: [ { flag: "--port ", - description: "Port to run on", + description: "Port to run on (also reads PORT env var)", default: "3000", }, { @@ -23,7 +23,18 @@ export const devHelp: CommandHelp = { examples: [ "veryfront dev", "veryfront dev --port 8080", + "PORT=3001 veryfront dev", "veryfront dev --open", "veryfront dev --no-hmr", ], + notes: [ + "Port selection priority (highest to lowest):", + " 1. --port / -p flag", + " 2. PORT env var", + " 3. VERYFRONT_PORT env var", + " 4. Default: 3000", + "", + "When the requested port is taken, the server falls forward to the next", + "free port. Open the URL the CLI prints to reach the running server.", + ], }; diff --git a/cli/commands/dev/command.ts b/cli/commands/dev/command.ts index c5f6393bc1..6c93615850 100644 --- a/cli/commands/dev/command.ts +++ b/cli/commands/dev/command.ts @@ -29,6 +29,14 @@ import { findAvailablePort, isPortAvailable, isPortInUseError } from "./port-fal export interface DevOptions { port: number; + /** + * True when the port was set explicitly by a `--port` / `-p` flag or by a + * valid `PORT` / `VERYFRONT_PORT` env var. When false (or absent) the port + * value fell through to the hardcoded default and `config.dev.port` should + * take precedence. Defaults to `port !== DEFAULT_DEV_PORT` for callers that + * do not set this field, preserving backward-compatible behaviour. + */ + portExplicit?: boolean; projectDir: string; hmr?: boolean; open?: boolean; @@ -152,6 +160,7 @@ export function devCommand(options: DevOptions): Promise { async () => { const { port, + portExplicit, projectDir, hmr = true, open = false, @@ -181,7 +190,14 @@ export function devCommand(options: DevOptions): Promise { } const DEFAULT_DEV_PORT = 3000; - const finalPort = port !== DEFAULT_DEV_PORT ? port : (config?.dev?.port ?? port); + // Use `portExplicit` when provided so that `PORT=3000` is honoured even + // though 3000 equals the default — the old sentinel `port !== 3000` would + // silently discard an explicit env-var request that happens to equal the + // default value. Fall back to the sentinel for callers that predate this + // field. + const finalPort = (portExplicit ?? port !== DEFAULT_DEV_PORT) + ? port + : (config?.dev?.port ?? port); const enableHMR = config?.dev?.hmr !== false && hmr; if (clearLocalCaches) await clearLocalCachesIfPortFree(finalPort); diff --git a/cli/commands/dev/handler.test.ts b/cli/commands/dev/handler.test.ts index 389c2e807d..770d25f535 100644 --- a/cli/commands/dev/handler.test.ts +++ b/cli/commands/dev/handler.test.ts @@ -4,7 +4,7 @@ import "#veryfront/schemas/_test-setup.ts"; */ import { assertEquals } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { parseCliArgs } from "#cli/shared/args"; import { handleDevCommand, parseDevArgs } from "./handler.ts"; import type { ParsedArgs } from "#cli/shared/types"; @@ -76,4 +76,93 @@ describe("commands/dev/handler", () => { assertEquals(args.port, undefined); }); }); + + describe("PORT env var", () => { + let savedPort: string | undefined; + let savedVeryfrontPort: string | undefined; + + beforeEach(() => { + savedPort = Deno.env.get("PORT"); + savedVeryfrontPort = Deno.env.get("VERYFRONT_PORT"); + Deno.env.delete("PORT"); + Deno.env.delete("VERYFRONT_PORT"); + }); + + afterEach(() => { + if (savedPort === undefined) Deno.env.delete("PORT"); + else Deno.env.set("PORT", savedPort); + if (savedVeryfrontPort === undefined) Deno.env.delete("VERYFRONT_PORT"); + else Deno.env.set("VERYFRONT_PORT", savedVeryfrontPort); + }); + + it("uses PORT env var as the default port when --port is not passed", () => { + Deno.env.set("PORT", "3001"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3001); + }); + + it("--port flag wins over PORT env var", () => { + Deno.env.set("PORT", "3001"); + const result = parseDevArgs(parseCliArgs(["dev", "--port", "4000"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 4000); + }); + + it("-p alias also wins over PORT env var", () => { + Deno.env.set("PORT", "3001"); + const result = parseDevArgs(parseCliArgs(["dev", "-p", "4000"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 4000); + }); + + it("uses VERYFRONT_PORT when PORT is not set", () => { + Deno.env.set("VERYFRONT_PORT", "3001"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3001); + }); + + it("PORT takes precedence over VERYFRONT_PORT", () => { + Deno.env.set("PORT", "4000"); + Deno.env.set("VERYFRONT_PORT", "3001"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 4000); + }); + + it("falls back to 3000 when PORT is not a valid integer", () => { + Deno.env.set("PORT", "not-a-port"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3000); + }); + + it("rejects PORT with trailing garbage (PORT=3001abc) — full string must be digits", () => { + Deno.env.set("PORT", "3001abc"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3000); + }); + + it("rejects PORT=0 as outside the valid range (1-65535)", () => { + Deno.env.set("PORT", "0"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3000); + }); + + it("rejects PORT=65536 as outside the valid range (1-65535)", () => { + Deno.env.set("PORT", "65536"); + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3000); + }); + + it("uses default 3000 when neither PORT nor VERYFRONT_PORT are set", () => { + const result = parseDevArgs(parseCliArgs(["dev"])); + assertEquals(result.success, true); + if (result.success) assertEquals(result.data.port, 3000); + }); + }); }); diff --git a/cli/commands/dev/handler.ts b/cli/commands/dev/handler.ts index e85c2cad52..7a780d542a 100644 --- a/cli/commands/dev/handler.ts +++ b/cli/commands/dev/handler.ts @@ -4,14 +4,82 @@ import { defineSchema, lazySchema } from "veryfront/schemas"; import { isAbsolute, join } from "veryfront/platform/path"; -import { cwd, setEnv } from "veryfront/platform"; +import { cwd, getEnv, setEnv } from "veryfront/platform"; import { createFileSystem } from "veryfront/platform"; -import { cliLogger, DEFAULT_DEV_SERVER_PORT, showHeader } from "#cli/utils"; +import { cliLogger, DEFAULT_DEV_SERVER_PORT, logWarning, showHeader } from "#cli/utils"; import { refreshLoggerConfig } from "veryfront/utils"; import { createArgParser, parseArgsOrThrow } from "#cli/shared/args"; import { ensureCliBundlerContracts } from "#cli/shared/default-contracts"; import type { ParsedArgs } from "#cli/shared/types"; +/** + * Parse a port from an env var string. Returns `undefined` when the var is + * absent, empty, not an all-digit integer, or outside the valid port range + * 1–65535. Invalid values emit a warning so the developer sees exactly what + * was rejected rather than getting a silent fallback. + * + * `Number.parseInt("3001abc")` silently returns 3001, so this function requires + * the entire trimmed string to be digits before converting — no prefix parsing. + */ +function parsePortEnv(name: string): number | undefined { + const raw = getEnv(name); + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed === "") return undefined; + if (!/^\d+$/.test(trimmed)) { + logWarning(`${name}=${JSON.stringify(raw)} is not a valid port number; ignoring`); + return undefined; + } + const port = Number(trimmed); + if (port < 1 || port > 65535) { + logWarning(`${name}=${port} is outside the valid port range (1-65535); ignoring`); + return undefined; + } + return port; +} + +/** + * Read a numeric port from an env var, returning `fallback` when the var is + * absent or contains a value that fails strict validation (non-integer string, + * trailing garbage, or a value outside the 1-65535 range). + */ +function readPortEnv(name: string, fallback: number): number { + return parsePortEnv(name) ?? fallback; +} + +/** + * Returns true when the named env var holds a valid port number (all-digit + * string within 1–65535). This is a pure predicate — it never emits warnings, + * because those were already emitted by `parsePortEnv` during arg parsing. + * Used to determine whether the port came from an explicit env var rather than + * falling through to the hardcoded default. + */ +function isValidPortEnv(name: string): boolean { + const raw = getEnv(name); + if (!raw) return false; + const t = raw.trim(); + if (!/^\d+$/.test(t)) return false; + const n = Number(t); + return n >= 1 && n <= 65535; +} + +/** + * The default port to use for `veryfront dev`, resolved from env vars. + * + * Priority (highest → lowest): + * 1. `--port` / `-p` flag — explicit, handled by `parseDevArgs` + * 2. `PORT` — the near-universal PaaS / framework convention + * 3. `VERYFRONT_PORT` — Veryfront-specific override + * 4. 3000 — hardcoded default + * + * This matches how `veryfront serve` handles the same env vars, and how + * Next.js, Vite, Create React App, Heroku, and Railway all treat `PORT`. + */ +function getDefaultDevPort(): number { + const veryfrontPort = readPortEnv("VERYFRONT_PORT", DEFAULT_DEV_SERVER_PORT); + return readPortEnv("PORT", veryfrontPort); +} + const getDevArgsSchema = defineSchema((v) => v.object({ port: v.number().default(DEFAULT_DEV_SERVER_PORT), @@ -25,7 +93,7 @@ const getDevArgsSchema = defineSchema((v) => const DevArgsSchema = lazySchema(getDevArgsSchema); -export const parseDevArgs = createArgParser(DevArgsSchema, { +const parseDevArgsBase = createArgParser(DevArgsSchema, { port: { keys: ["port", "p"], type: "number" }, project: { keys: ["project"], type: "string" }, hmr: { keys: ["hmr"], type: "boolean" }, @@ -34,6 +102,25 @@ export const parseDevArgs = createArgParser(DevArgsSchema, { debug: { keys: ["debug", "d"], type: "boolean" }, }); +/** + * Parses dev command arguments, honouring `PORT` / `VERYFRONT_PORT` as + * lower-precedence defaults when no explicit `--port` / `-p` is given. + */ +export const parseDevArgs: typeof parseDevArgsBase = (args) => { + const result = parseDevArgsBase(args); + if (!result.success) return result; + + return { + success: true, + data: { + ...result.data, + port: args.port === undefined && args.p === undefined + ? getDefaultDevPort() + : result.data.port, + }, + }; +}; + async function resolveProjectDir(projectArg: string | undefined): Promise { if (projectArg) { const projectDir = isAbsolute(projectArg) ? projectArg : join(cwd(), projectArg); @@ -59,6 +146,30 @@ async function resolveProjectDir(projectArg: string | undefined): Promise { const opts = parseArgsOrThrow(parseDevArgs, "dev", args); showHeader(); + + // Warn when `PORT` is set but `--port` overrides it. The developer set an + // env var that the server is not going to use, and silent divergence is the + // original defect this fixes. Only `PORT` is surfaced here (not + // `VERYFRONT_PORT`) because `PORT` is the near-universal convention that + // developers arriving from Next.js, Vite, Heroku, and Railway expect to work. + // + // `portExplicit` is also used to carry provenance into devCommand so that + // `PORT=3000` is honoured even when 3000 equals the hardcoded default — the + // sentinel `port !== 3000` check in devCommand must not swallow an explicit + // env var that happens to equal the default value. + const portExplicit = args.port !== undefined || + args.p !== undefined || + isValidPortEnv("PORT") || + isValidPortEnv("VERYFRONT_PORT"); + if (args.port !== undefined || args.p !== undefined) { + const portFromEnv = parsePortEnv("PORT"); + if (portFromEnv !== undefined && opts.port !== portFromEnv) { + logWarning( + `PORT=${portFromEnv} is set but --port ${opts.port} takes precedence`, + ); + } + } + await ensureCliBundlerContracts(); const projectDir = await resolveProjectDir(opts.project); @@ -72,6 +183,7 @@ export async function handleDevCommand(args: ParsedArgs): Promise { const { devCommand } = await import("./index.ts"); const { done } = await devCommand({ port: opts.port, + portExplicit, projectDir, hmr: opts.hmr && !opts.noHmr, open: opts.open, diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 40ddc50522..754694cbf6 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -87,15 +87,19 @@ The CLI prints the URL it is serving on: `veryfront.me` resolves to `127.0.0.1`, so [http://localhost:3000](http://localhost:3000) reaches the same server. -The dev server binds port 3000. When that port is already taken, `veryfront dev` -prints `! Port 3000 is in use, using 3001 instead` and serves on the first free -port after 3000, so open the URL the CLI prints. Pass `--port` to pin one -yourself: +The dev server uses port 3000 by default. You can also set the `PORT` env var +instead of the flag; `veryfront dev` reads it as a lower-precedence default, +the same way Next.js, Vite, Heroku, and Railway all treat `PORT`: ```bash -veryfront dev --port 4000 +PORT=3001 veryfront dev # bind 3001 +veryfront dev --port 4000 # --port wins over PORT when both are set ``` +When the requested port is already taken, `veryfront dev` prints +`! Port 3001 is in use, using 3002 instead` and serves on the first free port, +so open the URL the CLI prints. + `veryfront dev` also starts the development MCP server two ports above the port the dev server bound, so it moves with the app port when that falls forward. With the default app port, coding agents can connect to