diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 51c5a1469e..ef07570b74 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -14,6 +14,7 @@ import { prebuildPreview, handlePreviewKillAll, handlePreviewList, + previewHostAppError, previewLaunchMode, previewLaunchModeError, previewPortError, @@ -601,3 +602,36 @@ describe("studio dev-server spawns", () => { expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true }); }); }); + +describe("preview inside a host app's agent run", () => { + const hostRun = { HYPERFRAMES_DESKTOP_PROJECT: "/projects/demo" }; + + it("refuses a background preview, which would outlive the run", () => { + expect(previewHostAppError("background", hostRun)).toMatch(/host app .* owns its preview/); + expect(previewHostAppError("background", hostRun)).toContain("--foreground"); + }); + + it("allows a preview attached to the run, and background outside a host app", () => { + expect(previewHostAppError("embedded", hostRun)).toBeNull(); + expect(previewHostAppError("local", hostRun)).toBeNull(); + expect(previewHostAppError("background", {})).toBeNull(); + }); + + it("refuses before spawning anything", async () => { + vi.stubEnv("HYPERFRAMES_DESKTOP_PROJECT", "/projects/demo"); + const project = mkdtempSync(join(tmpdir(), "hf-host-preview-")); + writeFileSync(join(project, "index.html"), ""); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + let printed = ""; + try { + await runCommand(previewCommand, { rawArgs: [project, "--background", "--json"] }); + printed = String(log.mock.calls.at(-1)?.[0]); + } finally { + log.mockRestore(); + vi.unstubAllEnvs(); + rmSync(project, { recursive: true, force: true }); + } + const payload = JSON.parse(printed); + expect(payload).toMatchObject({ ok: false, error: { code: "host-app-owns-preview" } }); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 9112f15b83..244dfd2287 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -39,7 +39,7 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; -import { desktopHint } from "../utils/desktopApp.js"; +import { desktopHint, insideHostApp } from "../utils/desktopApp.js"; import { isDevMode } from "../utils/env.js"; import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js"; import { buildNpxCommand } from "../utils/npxCommand.js"; @@ -488,9 +488,6 @@ export default defineCommand({ ); } - // Runs in the CLI worker while Studio starts, so it never delays the opening; --json skips it. - const startupLint = args.json ? null : printStartupLint(dir, Boolean(args["lint-verbose"])); - const launchMode = previewLaunchMode({ background: Boolean(args.background), foreground: Boolean(args.foreground), @@ -499,6 +496,15 @@ export default defineCommand({ localStudio: hasLocalStudio(dir), }); + const hostAppError = previewHostAppError(launchMode); + if (hostAppError) { + reportPreviewFailure(Boolean(args.json), "start", "host-app-owns-preview", hostAppError); + return; + } + + // Runs in the CLI worker while Studio starts, so it never delays the opening; --json skips it. + const startupLint = args.json ? null : printStartupLint(dir, Boolean(args["lint-verbose"])); + if (launchMode === "background") { let background; try { @@ -643,6 +649,18 @@ export function previewLaunchMode(options: { return options.localStudio ? "local" : "embedded"; } +export function previewHostAppError( + mode: PreviewLaunchMode, + env: NodeJS.ProcessEnv = process.env, +): string | null { + if (mode !== "background" || !insideHostApp(env)) return null; + return ( + "A host app is running this project and owns its preview, so a background preview " + + "here would outlive this run and keep the project's history locked. Use the host " + + "app's preview, or run with --foreground to keep a preview attached to this run." + ); +} + export function previewLaunchModeError(options: { background: boolean; foreground: boolean; diff --git a/packages/cli/src/utils/desktopApp.ts b/packages/cli/src/utils/desktopApp.ts index 36753756b3..64cd7dcdfa 100644 --- a/packages/cli/src/utils/desktopApp.ts +++ b/packages/cli/src/utils/desktopApp.ts @@ -221,6 +221,10 @@ export function desktopInstalled({ return DESKTOP_BUNDLE_IDS.some(spotlight); } +export function insideHostApp(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.HYPERFRAMES_DESKTOP_PROJECT); +} + /** The app line render and preview print; null inside the app, or with nothing here to open or download. */ export function desktopHint( dir: string, @@ -236,7 +240,7 @@ export function desktopHint( platform?: NodeJS.Platform; } = {}, ): string | null { - if (env.HYPERFRAMES_DESKTOP_PROJECT) return null; + if (insideHostApp(env)) return null; if (ready && (installed ?? desktopInstalled({ platform, env }))) return `Keep editing by chatting with Framey in the desktop app: ${openCommandFor(dir)}`; const url = desktopDownloadUrl(platform);