feat(cli): give every preview lifecycle op one JSON document (#3309)

`--status`, `--stop`, `--list` and `--kill-all` emit a schema-versioned
envelope with an `ok` discriminant under `--json`, from one writer and one
failure-payload builder. Human output is unchanged; the JSON path is additive.

The value is in the failure paths. An agent that gets a bare error line on
stderr and an empty stdout cannot tell a crash from a "not running", so every
failure is a document too — including a missing project, which under `--json`
resolves through the throwing resolver rather than the human-shaped nudge.
This commit is contained in:
Miguel Ángel
2026-08-18 20:07:50 -04:00
committed by GitHub
parent e282ff15cc
commit 0e3c5f6bef
4 changed files with 473 additions and 84 deletions
+101 -5
View File
@@ -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();
});
});
+219 -79
View File
@@ -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<void> {
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<void> {
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
@@ -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",
},
});
});
});
@@ -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));
}