Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const commandLoaders = {
preview: () =>
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
publish: () => import("./commands/publish.js").then((m) => m.default),
open: () => import("./commands/open.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { setCommandExitCode } from "../utils/commandResult.js";
import { DOWNLOAD_HINT, openInDesktop, type DesktopOpenResult } from "../utils/desktopApp.js";
import { resolveProject, resolveProjectOrThrow, type ProjectDir } from "../utils/project.js";

export const examples: Example[] = [
["Open this project in HyperFrames Studio", "hyperframes open"],
["Open another project", "hyperframes open ./my-video"],
["For agents", "hyperframes open --json"],
];

const WHY_NOT: Record<Extract<DesktopOpenResult, { opened: false }>["reason"], string | null> = {
"handoff-unavailable": null,
"unsupported-platform": "Opening a project from the CLI works on macOS only.",
"not-installed": "HyperFrames Studio isn't installed on this Mac.",
"open-failed": "macOS couldn't open HyperFrames Studio.",
};

function printResult(project: ProjectDir, result: DesktopOpenResult): void {
if (result.opened) {
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in HyperFrames Studio`);
if (result.handedOver) {
const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex";
console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`);
}
return;
}
const why = WHY_NOT[result.reason];
if (!why) return console.log(`${c.warn("◇")} ${DOWNLOAD_HINT}`);
console.log(`${c.warn("◇")} ${why}`);
console.log(` Download it: ${c.accent(result.downloadUrl)}`);
}

/** Under --json a bad directory answers in JSON too, not with the human error box. */
function projectForJson(dir: string | undefined): ProjectDir | null {
try {
return resolveProjectOrThrow(dir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({ ok: false, error: { code: "invalid-project", message } }));
setCommandExitCode(1);
return null;
}
}

export default defineCommand({
meta: { name: "open", description: "Open a project in HyperFrames Studio, the desktop app" },
args: {
dir: {
type: "positional",
description: "Project directory (default: current)",
required: false,
},
json: { type: "boolean", description: "Output as JSON", default: false },
},
run({ args }) {
const project = args.json ? projectForJson(args.dir) : resolveProject(args.dir);
if (!project) return;
const result = openInDesktop(project.dir);
if (!result.opened) setCommandExitCode(1);
if (args.json) console.log(JSON.stringify({ project: project.dir, ...result }, null, 2));
else printResult(project, result);
},
});
14 changes: 9 additions & 5 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +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 { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
Expand Down Expand Up @@ -365,7 +366,7 @@ export default defineCommand({
);
return;
}
printStudioSummary(project.name, previewBaseUrl(status.port), {
printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, {
details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`],
});
return;
Expand Down Expand Up @@ -539,7 +540,7 @@ export default defineCommand({
);
} else {
clack.intro(c.bold("hyperframes preview"));
printStudioSummary(projectName, url, {
printStudioSummary(projectName, url, dir, {
details: [
background.type === "reused"
? "Reusing the background server already running for this project."
Expand Down Expand Up @@ -1282,13 +1283,16 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa
function printStudioSummary(
projectName: string,
serverUrl: string,
projectDir: string,
opts: { details?: string[]; footer?: string } = {},
): void {
const urls = studioSummaryUrls(projectName, serverUrl);
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`);
console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`);
const hint = desktopHint(projectDir);
if (hint) console.log(` ${c.dim("Desktop")} ${hint}`);
console.log();
for (const detail of opts.details ?? []) {
console.log(` ${c.dim(detail)}`);
Expand Down Expand Up @@ -1395,7 +1399,7 @@ function attachStudioReadyHandler(
);
} else {
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, {
printStudioSummary(projectName, url, projectDir, {
footer: "Press Ctrl+C to stop",
});
}
Expand Down Expand Up @@ -1629,7 +1633,7 @@ async function runEmbeddedMode(
);
} else {
s.stop(c.success("Already running"));
printStudioSummary(pName, url, {
printStudioSummary(pName, url, dir, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
}
Expand All @@ -1647,7 +1651,7 @@ async function runEmbeddedMode(
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
console.log();
}
printStudioSummary(pName, url, {
printStudioSummary(pName, url, dir, {
details: [
"Edit with your AI agent — it has HyperFrames skills installed.",
"Changes reload automatically in the studio.",
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { resolve, dirname, join, basename } from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { desktopHint } from "../utils/desktopApp.js";
import {
formatBytes,
formatRenderSummaryDetail,
Expand Down Expand Up @@ -524,6 +525,8 @@ export interface RenderOptions {
throwOnError?: boolean;
/** Skip the interactive feedback prompt after a successful render. */
skipFeedback?: boolean;
/** False for a batch row: one line about the desktop app per batch is noise, not a pointer. */
desktopHint?: boolean;
/**
* OPT IN to managing the DE parallel-router circuit breaker
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
Expand Down Expand Up @@ -886,6 +889,8 @@ async function renderDocker(
runPostRenderStep("printRenderComplete", () =>
printRenderComplete({
outputPath,
projectDir,
desktopHint: wantsDesktopHint(options),
elapsedMs: elapsed,
quiet: options.quiet,
format: options.format,
Expand Down Expand Up @@ -1138,6 +1143,8 @@ async function executeLocalRender(
runPostRenderStep("printRenderComplete", () =>
printRenderComplete({
outputPath,
projectDir,
desktopHint: wantsDesktopHint(options),
elapsedMs: elapsed,
quiet: options.quiet,
format: options.format,
Expand Down Expand Up @@ -1898,8 +1905,16 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
}
}

/** A delivered render points to the desktop app; a draft or a batch row does not. */
export function wantsDesktopHint(options: Pick<RenderOptions, "desktopHint" | "quality">): boolean {
return options.desktopHint !== false && options.quality !== "draft";
}

function printRenderComplete(input: {
outputPath: string;
projectDir: string;
/** Print the desktop-app line: a delivered render, never a draft or a batch row. */
desktopHint: boolean;
elapsedMs: number;
quiet: boolean;
format: RenderFormat;
Expand All @@ -1920,6 +1935,8 @@ function printRenderComplete(input: {
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
const hint = input.desktopHint ? desktopHint(input.projectDir) : null;
if (hint) console.log(" " + c.dim(hint));
}

function printRenderPipeline(
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/commands/render/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRenderPlan } from "./plan.js";
import { renderOptionsFromPlan } from "./execute.js";
import { batchRowRenderOptions, renderOptionsFromPlan } from "./execute.js";
import { wantsDesktopHint } from "../render.js";

describe("renderOptionsFromPlan", () => {
let projectDir: string;
Expand Down Expand Up @@ -40,3 +41,19 @@ describe("renderOptionsFromPlan", () => {
expect(on.variables).toEqual({ title: "x" });
});
});

describe("the desktop-app line after a render", () => {
it("comes after a delivered render, never after a draft or a batch row", () => {
expect(wantsDesktopHint({ quality: "high" })).toBe(true);
expect(wantsDesktopHint({ quality: "draft" })).toBe(false);
expect(wantsDesktopHint({ quality: "high", desktopHint: false })).toBe(false);
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-batch-"));
try {
writeFileSync(join(projectDir, "index.html"), '<main data-composition-id="main"></main>');
const row = batchRowRenderOptions(createRenderPlan({ dir: projectDir }), undefined);
expect(wantsDesktopHint(row)).toBe(false);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});
23 changes: 16 additions & 7 deletions packages/cli/src/commands/render/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,16 +320,12 @@ async function runResolutionPreflight(
failCommand();
}

async function executeBatchRender(
export function batchRowRenderOptions(
plan: RenderPlan,
browserPath: string | undefined,
batchModule: typeof import("../batchRender.js"),
preparedBatch: import("../batchRender.js").PreparedBatchRender,
dependencies: RenderExecutionDependencies,
cancellation?: RenderCancellationScope,
): Promise<void> {
): RenderOptions {
const batchQuiet = plan.quiet || plan.batchJson;
const renderOptionsBase: RenderOptions = {
return {
fps: plan.fps,
quality: plan.quality,
authoringSkill: plan.authoringSkill,
Expand Down Expand Up @@ -365,8 +361,21 @@ async function executeBatchRender(
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
desktopHint: false,
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
};
}

async function executeBatchRender(
plan: RenderPlan,
browserPath: string | undefined,
batchModule: typeof import("../batchRender.js"),
preparedBatch: import("../batchRender.js").PreparedBatchRender,
dependencies: RenderExecutionDependencies,
cancellation?: RenderCancellationScope,
): Promise<void> {
const batchQuiet = plan.quiet || plan.batchJson;
const renderOptionsBase = batchRowRenderOptions(plan, browserPath);
const manifest = await batchModule.runBatchRender({
prepared: preparedBatch,
concurrency: plan.batchConcurrency,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const GROUPS: Group[] = [
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
["publish", "Upload a project to a stable URL (private by default)"],
["render", "Render a composition to MP4 or WebM"],
["open", "Open a project in HyperFrames Studio, the desktop app"],
],
},
{
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/server/desktopRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import type { DesktopOpenResult } from "../utils/desktopApp.js";
import { mountDesktopRoutes, sameOriginPost } from "./desktopRoutes.js";

const STUDIO = {
host: "localhost:3002",
origin: "http://localhost:3002",
"sec-fetch-site": "same-origin",
};
const OPENED: DesktopOpenResult = {
opened: true,
bundleId: "dev.hyperframes.desktop",
handedOver: null,
};

function server(ready: boolean) {
const app = new Hono();
const open = vi.fn((_dir: string): DesktopOpenResult => OPENED);
mountDesktopRoutes(app, "/films/a", { ready, open });
const post = (headers: Record<string, string>) =>
app.request("/api/open-in-desktop", { method: "POST", headers });
return { app, open, post };
}

describe("open-in-desktop routes", () => {
it("tells Studio whether to show the button and whether the app can take the project", async () => {
const body = await (await server(false).app.request("/api/open-in-desktop")).json();
expect(body).toEqual({ available: process.platform === "darwin", handoff: false });
});

it("refuses a POST from another site, or through a rebound host name", async () => {
const { open, post } = server(true);
const crossSite = { ...STUDIO, origin: "https://evil.example", "sec-fetch-site": "cross-site" };
expect((await post(crossSite)).status).toBe(403);
expect(
(await post({ ...STUDIO, host: "evil.example:3002", origin: "http://evil.example:3002" }))
.status,
).toBe(403);
expect(open).not.toHaveBeenCalled();
});

it("while gated, answers Studio's own POST with the download and opens nothing", async () => {
const { open, post } = server(false);
const res = await post(STUDIO);
expect(await res.json()).toMatchObject({ opened: false, reason: "handoff-unavailable" });
expect(open).not.toHaveBeenCalled();
});

it("once live, opens this server's project for Studio's own POST", async () => {
const { open, post } = server(true);
expect(await (await post(STUDIO)).json()).toEqual(OPENED);
expect(open).toHaveBeenCalledWith("/films/a");
});
});

describe("sameOriginPost", () => {
it("lets a local process without browser headers through, never a foreign origin", () => {
expect(sameOriginPost({ host: "127.0.0.1:3002" })).toBe(true);
expect(sameOriginPost({ host: "localhost:3002", origin: "http://localhost:3003" })).toBe(false);
expect(sameOriginPost({ host: "localhost:3002", fetchSite: "same-site" })).toBe(false);
expect(sameOriginPost({})).toBe(false);
});
});
41 changes: 41 additions & 0 deletions packages/cli/src/server/desktopRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Hono } from "hono";
import { DESKTOP_DOWNLOAD_URL, HANDOFF_READY, openInDesktop } from "../utils/desktopApp.js";
import { identityAllowed } from "./telemetryIdentity.js";

/** A bodiless POST is a simple request, so any page can send one to localhost: it has to come from this Studio. */
export function sameOriginPost(headers: {
host?: string;
origin?: string;
fetchSite?: string;
}): boolean {
const { host, origin, fetchSite } = headers;
if (!host || !identityAllowed(host)) return false;
if (fetchSite && fetchSite !== "same-origin") return false;
return origin === undefined || origin === `http://${host}`;
}

/** Studio's Edit with Framey: the GET says whether to show it (macOS) and whether the app can take the project. */
export function mountDesktopRoutes(
app: Hono,
projectDir: string,
{ ready = HANDOFF_READY, open = openInDesktop } = {},
): void {
app.get("/api/open-in-desktop", (c) =>
c.json({ available: process.platform === "darwin", handoff: ready }),
);
app.post("/api/open-in-desktop", (c) => {
const allowed = sameOriginPost({
host: c.req.header("host"),
origin: c.req.header("origin"),
fetchSite: c.req.header("sec-fetch-site"),
});
if (!allowed) return c.json({ error: "forbidden" }, 403);
if (!ready)
return c.json({
opened: false,
reason: "handoff-unavailable",
downloadUrl: DESKTOP_DOWNLOAD_URL,
});
return c.json(open(projectDir));
});
}
Loading
Loading