diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index f35a7e4680..ae1646c467 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import * as clack from "@clack/prompts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { handlePreviewKillAll, handlePreviewList, studioLandingSearch } from "./preview.js"; +import { runCommand } from "citty"; +import { + default as previewCommand, + handlePreviewKillAll, + handlePreviewList, + studioLandingSearch, +} from "./preview.js"; const tempDirs: string[] = []; @@ -67,9 +73,9 @@ describe("preview --kill-all", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); const warn = vi.spyOn(clack.log, "warn").mockImplementation(() => {}); - await handlePreviewKillAll(3002, { + await handlePreviewKillAll(3002, false, { listManaged: async () => [session(41402, "/tmp/unprovable"), session(41403, "/tmp/healthy")], - stopManaged: async (projectDir) => { + stopManaged: async (projectDir: string) => { if (projectDir === "/tmp/unprovable") throw new Error("ownership failed"); return true; }, @@ -85,7 +91,7 @@ describe("preview --kill-all", () => { it("reports nothing to kill when no preview is running", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - await handlePreviewKillAll(3002, { + await handlePreviewKillAll(3002, false, { listManaged: async () => [], killScanned: async () => ({ killed: 0, unverified: [] }), }); @@ -99,7 +105,7 @@ describe("preview --list", () => { it("prefers the managed record over the same server's own self-report", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - await handlePreviewList(3002, { + await handlePreviewList(3002, false, { listManaged: async () => [ { pid: 99, port: 3002, projectDir: resolve("/tmp/demo"), logPath: "/tmp/demo.log" }, ], @@ -120,3 +126,93 @@ describe("preview --list", () => { log.mockRestore(); }); }); + +describe("preview lifecycle JSON failures", () => { + it.each([ + [ + "list", + () => + handlePreviewList(3002, true, { + scan: async () => { + throw new Error("list probe failed"); + }, + listManaged: async () => [], + }), + "preview-list-failed", + ], + [ + "kill-all", + () => + handlePreviewKillAll(3002, true, { + listManaged: async () => [], + killScanned: async () => { + throw new Error("scan failed"); + }, + }), + "preview-kill-all-failed", + ], + ] as const)("wraps %s failures in one JSON document", async (operation, run, code) => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await run(); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation, + ok: false, + error: { code }, + }); + expect(error).not.toHaveBeenCalled(); + }); + + it("keeps stopping after a record whose ownership cannot be proven", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const session = (port: number, projectDir: string) => ({ + pid: 4321, + port, + projectDir, + logPath: `${projectDir}.log`, + }); + + await handlePreviewKillAll(3002, true, { + listManaged: async () => [session(41402, "/tmp/unprovable"), session(41403, "/tmp/healthy")], + stopManaged: async (projectDir) => { + if (projectDir === "/tmp/unprovable") throw new Error("ownership failed"); + return true; + }, + killScanned: async () => ({ killed: 0, unverified: [] }), + }); + + const [line] = log.mock.calls[0] as [string]; + // The second record must still be stopped AND the first must be reported: + // propagating the first failure left every later preview running, unlisted. + expect(JSON.parse(line)).toMatchObject({ + operation: "kill-all", + ok: true, + result: { state: "killed-all", stopped: 1, failed: ["/tmp/unprovable: ownership failed"] }, + }); + }); + + it("wraps stop failures in one JSON document", async () => { + const missing = join(tmpdir(), `hf-preview-missing-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [missing, "--stop", "--json"], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "stop", + ok: false, + error: { code: "preview-stop-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 85c306ef40..50ad19751b 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -61,7 +61,7 @@ import { type FindPortResult, } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; -import { resolveProject } from "../utils/project.js"; +import { resolveProject, resolveProjectOrThrow } from "../utils/project.js"; import { resolveAutoProxy } from "../utils/projectConfig.js"; import { studioProxyEnv } from "../utils/studioProxyEnv.js"; import { @@ -70,6 +70,13 @@ import { startBackgroundPreview, stopBackgroundPreview, } from "./previewLifecycle.js"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleOperation, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; interface BrowserLaunchOptions { @@ -215,38 +222,83 @@ export default defineCommand({ const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined; if (args.status || args.stop) { - const project = resolveProject(args.dir); - if (args.stop) { - const stopped = await stopBackgroundPreview(project.dir, startPort); + try { + // Under --json a missing project is a lifecycle failure document, not a + // human-shaped nudge, so the throwing resolver is the right one there. + const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir); + if (args.stop) { + const stopped = await stopBackgroundPreview(project.dir, startPort); + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "stop", + stopped + ? { state: "stopped", projectDir: project.dir } + : { state: "not-running", projectDir: project.dir }, + ), + ); + } else { + console.log( + stopped + ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` + : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, + ); + } + return; + } + const status = await readBackgroundPreviewStatus(project.dir, startPort); + if (!status) { + if (args.json) { + writeLifecycleJson( + lifecyclePayload("status", { state: "not-running", projectDir: project.dir }), + ); + } else { + console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + } + return; + } + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "status", + previewLifecycleSession({ + state: "running", + mode: "background", + projectName: project.name, + projectDir: project.dir, + port: status.port, + pid: status.pid, + logPath: status.logPath, + }), + ), + ); + return; + } + console.log(`\n ${c.success("Background preview running")}`); console.log( - stopped - ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` - : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, + ` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`, + ); + console.log(` ${c.dim(status.logPath)}\n`); + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + args.stop ? "stop" : "status", + args.stop ? "preview-stop-failed" : "preview-status-failed", + errorMessage(error), ); - return; - } - const status = await readBackgroundPreviewStatus(project.dir, startPort); - if (!status) { - console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); - return; } - console.log(`\n ${c.success("Background preview running")}`); - console.log( - ` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`, - ); - console.log(` ${c.dim(status.logPath)}\n`); return; } // --list: scan and display active servers if (args.list) { - await handlePreviewList(startPort); + await handlePreviewList(startPort, Boolean(args.json)); return; } // --kill-all: kill all active servers if (args["kill-all"]) { - await handlePreviewKillAll(startPort); + await handlePreviewKillAll(startPort, Boolean(args.json)); return; } @@ -421,6 +473,44 @@ export default defineCommand({ }, }); +function previewLifecycleSession(options: { + state: PreviewLifecycleSession["state"]; + mode: PreviewLifecycleSession["mode"]; + projectName: string; + projectDir: string; + port: number; + pid: number | null; + host?: string; + logPath?: string; +}): PreviewLifecycleSession { + const host = options.host ?? "127.0.0.1"; + const serverUrl = previewBaseUrl(options.port, host); + return { + state: options.state, + mode: options.mode, + projectName: options.projectName, + projectDir: options.projectDir, + host, + port: options.port, + pid: options.pid, + serverUrl, + studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir), + ready: true, + ...(options.logPath ? { logPath: options.logPath } : {}), + }; +} + +function reportPreviewFailure( + json: boolean, + operation: PreviewLifecycleOperation, + code: string, + message: string, +): void { + if (json) writeLifecycleJson(lifecycleFailurePayload(operation, code, message)); + else clack.log.error(message); + setCommandExitCode(1); +} + interface PreviewActionDependencies { scan?: typeof scanActiveServers; listManaged?: typeof listBackgroundPreviewStatuses; @@ -435,38 +525,64 @@ interface PreviewActionDependencies { */ export async function handlePreviewList( startPort: number, + json: boolean, dependencies: PreviewActionDependencies = {}, ): Promise { - const [scannedServers, managedSessions] = await Promise.all([ - (dependencies.scan ?? scanActiveServers)(startPort), - (dependencies.listManaged ?? listBackgroundPreviewStatuses)(), - ]); - const managedKeys = new Set( - managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), - ); - const servers = [ - ...managedSessions.map((session) => ({ - port: session.port, - projectName: basename(session.projectDir), - projectDir: session.projectDir, - pid: String(session.pid), - })), - ...scannedServers.filter( - (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), - ), - ]; - if (servers.length === 0) { - console.log("\n No active preview servers found.\n"); - return; - } - console.log(`\n ${c.bold("Active preview servers:")}\n`); - for (const server of servers) { - const pid = server.pid ? c.dim(` (PID ${server.pid})`) : ""; - console.log( - ` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`, + try { + const [scannedServers, managedSessions] = await Promise.all([ + (dependencies.scan ?? scanActiveServers)(startPort), + (dependencies.listManaged ?? listBackgroundPreviewStatuses)(), + ]); + const managedKeys = new Set( + managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), ); + const servers = [ + ...managedSessions.map((session) => ({ + port: session.port, + host: "127.0.0.1", + projectName: basename(session.projectDir), + projectDir: session.projectDir, + version: "managed", + pid: String(session.pid), + })), + ...scannedServers.filter( + (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), + ), + ]; + if (json) { + writeLifecycleJson( + lifecyclePayload("list", { + state: "listed", + sessions: servers.map((server) => + previewLifecycleSession({ + state: "running", + mode: server.version === "managed" ? "background" : "unknown", + projectName: server.projectName, + projectDir: server.projectDir, + port: server.port, + pid: server.pid ? Number(server.pid) : null, + host: server.host, + }), + ), + }), + ); + return; + } + if (servers.length === 0) { + console.log("\n No active preview servers found.\n"); + return; + } + console.log(`\n ${c.bold("Active preview servers:")}\n`); + for (const server of servers) { + const pid = server.pid ? c.dim(` (PID ${server.pid})`) : ""; + console.log( + ` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`, + ); + } + console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + } catch (error) { + reportPreviewFailure(json, "list", "preview-list-failed", errorMessage(error)); } - console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); } /** @@ -479,42 +595,66 @@ export async function handlePreviewList( */ export async function handlePreviewKillAll( startPort: number, + json: boolean, dependencies: PreviewActionDependencies = {}, ): Promise { - const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)(); - let killed = 0; - const failures: string[] = []; - for (const session of managedSessions) { - try { - if ( - await (dependencies.stopManaged ?? stopBackgroundPreview)(session.projectDir, session.port) - ) { - killed++; + try { + const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)(); + let killed = 0; + // One unprovable record must not abandon the servers after it. A stop pass + // collects per-record failures and keeps going; propagating the first one + // left every later preview running AND unreported. + const failures: string[] = []; + for (const session of managedSessions) { + try { + if ( + await (dependencies.stopManaged ?? stopBackgroundPreview)( + session.projectDir, + session.port, + ) + ) { + killed++; + } + } catch (error) { + failures.push(`${session.projectDir}: ${errorMessage(error)}`); } - } catch (error) { - failures.push(`${session.projectDir}: ${error instanceof Error ? error.message : error}`); } + const swept = await (dependencies.killScanned ?? killActiveServers)(startPort); + killed += swept.killed; + // Ports whose owner the OS could not confirm are skipped rather than + // signalled; an agent reading this envelope needs to see that too, not just + // a lower count. + const unverified = swept.unverified; + if (json) { + writeLifecycleJson( + lifecyclePayload("kill-all", { + state: "killed-all", + stopped: killed, + ...(failures.length > 0 ? { failed: failures } : {}), + ...(unverified.length > 0 ? { unverifiedPorts: unverified } : {}), + }), + ); + } else if (failures.length > 0 || unverified.length > 0) { + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.`); + for (const failure of failures) clack.log.warn(`Could not stop ${failure}`); + if (unverified.length > 0) { + const plural = unverified.length === 1 ? "" : "s"; + clack.log.warn( + `Left ${unverified.length} server${plural} alone (port${plural} ` + + `${unverified.join(", ")}): the OS could not confirm which process owns the ` + + `socket, and the server's own claim is not proof. Install lsof, or stop it ` + + `with its own preview --stop.`, + ); + } + console.log(); + } else if (killed === 0) { + console.log("\n No active preview servers to kill.\n"); + } else { + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + } + } catch (error) { + reportPreviewFailure(json, "kill-all", "preview-kill-all-failed", errorMessage(error)); } - const swept = await (dependencies.killScanned ?? killActiveServers)(startPort); - killed += swept.killed; - if (killed > 0) { - console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.`); - } else if (failures.length === 0 && swept.unverified.length === 0) { - console.log("\n No active preview servers to kill."); - } - for (const failure of failures) clack.log.warn(`Could not stop ${failure}`); - if (swept.unverified.length > 0) { - // Fail-closed, said out loud: a security control that degrades silently is - // one nobody knows to fix. - const ports = swept.unverified.join(", "); - const plural = swept.unverified.length === 1 ? "" : "s"; - clack.log.warn( - `Left ${swept.unverified.length} server${plural} alone (port${plural} ${ports}): the OS ` + - `could not confirm which process owns the socket, and the server's own claim is not ` + - `proof. Install lsof, or stop it with its own preview --stop.`, - ); - } - console.log(); } // `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded diff --git a/packages/cli/src/commands/previewLifecycleOutput.test.ts b/packages/cli/src/commands/previewLifecycleOutput.test.ts new file mode 100644 index 0000000000..780b4e876c --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; + +const session: PreviewLifecycleSession = { + state: "started", + mode: "background", + projectName: "demo", + projectDir: "/tmp/demo", + host: "127.0.0.1", + port: 3002, + pid: 42, + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + ready: true, + logPath: "/tmp/demo.log", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("preview lifecycle JSON", () => { + it("versions a managed start result", () => { + expect(lifecyclePayload("start", session)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: session, + }); + }); + + it.each([ + ["status", { state: "not-running", projectDir: "/tmp/demo" }], + ["stop", { state: "stopped", projectDir: "/tmp/demo" }], + ["list", { state: "listed", sessions: [session] }], + ["kill-all", { state: "killed-all", stopped: 1 }], + ] as const)("versions the %s result", (operation, result) => { + expect(lifecyclePayload(operation, result)).toMatchObject({ + schemaVersion: 1, + operation, + ok: true, + result, + }); + }); + + it("writes exactly one parseable JSON document", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + writeLifecycleJson(lifecyclePayload("start", { ...session, pid: null, state: "reused" })); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { ...session, pid: null, state: "reused" }, + }); + }); + + it("versions lifecycle failures with a stable code", () => { + expect( + lifecycleFailurePayload( + "start", + "conflicting-lifecycle-flags", + "--background and --foreground cannot be used together", + ), + ).toEqual({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { + code: "conflicting-lifecycle-flags", + message: "--background and --foreground cannot be used together", + }, + }); + }); +}); diff --git a/packages/cli/src/commands/previewLifecycleOutput.ts b/packages/cli/src/commands/previewLifecycleOutput.ts new file mode 100644 index 0000000000..53b2b4fc6d --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.ts @@ -0,0 +1,70 @@ +export type PreviewLifecycleOperation = "start" | "status" | "stop" | "list" | "kill-all"; + +export type PreviewLifecycleState = "started" | "reused" | "running"; + +export interface PreviewLifecycleSession { + state: PreviewLifecycleState; + mode: "background" | "foreground" | "unknown"; + projectName: string; + projectDir: string; + host: string; + port: number; + pid: number | null; + serverUrl: string; + studioUrl: string; + ready: boolean; + logPath?: string; +} + +export type PreviewLifecycleResult = + | PreviewLifecycleSession + | { state: "not-running"; projectDir?: string } + | { state: "stopped"; projectDir: string } + | { state: "listed"; sessions: readonly PreviewLifecycleSession[] } + /** + * `failed` carries the records a stop pass could not prove ownership of; + * `unverifiedPorts` the ones skipped because the OS would not confirm which + * process owns the socket. Both are omissions from `stopped`, so an agent + * that only reads the count would otherwise see a silent shortfall. + */ + | { + state: "killed-all"; + stopped: number; + failed?: readonly string[]; + unverifiedPorts?: readonly number[]; + }; + +export interface PreviewLifecyclePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: true; + result: PreviewLifecycleResult; +} + +export interface PreviewLifecycleFailurePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: false; + error: { code: string; message: string }; +} + +export function lifecyclePayload( + operation: PreviewLifecycleOperation, + result: PreviewLifecycleResult, +): PreviewLifecyclePayload { + return { schemaVersion: 1, operation, ok: true, result }; +} + +export function lifecycleFailurePayload( + operation: PreviewLifecycleOperation, + code: string, + message: string, +): PreviewLifecycleFailurePayload { + return { schemaVersion: 1, operation, ok: false, error: { code, message } }; +} + +export function writeLifecycleJson( + payload: PreviewLifecyclePayload | PreviewLifecycleFailurePayload, +): void { + console.log(JSON.stringify(payload)); +}