feat(cli): expose Studio selection through preview (#1777)

Add a small Studio selection channel so agents can ask a running preview server
for the element the user selected in Studio. This keeps the UX on the existing
npx hyperframes preview surface while giving agents a stable source file,
target selector, timeline time, and thumbnail URL for follow-up edits.
This commit is contained in:
Miguel Ángel
2026-06-28 15:15:57 -04:00
committed by GitHub
parent fc0f8c3151
commit 9983f37c13
17 changed files with 1511 additions and 205 deletions
+562 -199
View File
@@ -1,9 +1,12 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { spawn } from "node:child_process";
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
export const examples: Example[] = [
["Preview the current project", "hyperframes preview"],
["Print the current Studio selection as JSON", "hyperframes preview --selection --json"],
["Print current Studio context as JSON", "hyperframes preview --context --json"],
["Preview a specific project directory", "hyperframes preview ./my-video"],
["Use a custom port", "hyperframes preview --port 8080"],
["Force a new server even if one is already running", "hyperframes preview --force-new"],
@@ -24,6 +27,7 @@ import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import {
openBrowser,
parseRemoteDebuggingPort,
@@ -40,6 +44,40 @@ import {
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";
interface BrowserLaunchOptions {
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
}
interface StudioLaunchOptions extends BrowserLaunchOptions {
projectName?: string;
}
interface EmbeddedStudioOptions extends StudioLaunchOptions {
forceNew?: boolean;
}
type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
type ContextField = "server" | "selection" | "lint" | "capabilities";
type CompactSelectionPayload = Pick<
StudioSelectionSnapshot,
| "schemaVersion"
| "projectId"
| "compositionPath"
| "sourceFile"
| "currentTime"
| "target"
| "label"
| "tagName"
| "boundingBox"
| "textContent"
| "thumbnailUrl"
>;
const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];
export default defineCommand({
meta: { name: "preview", description: "Start the studio for previewing compositions" },
args: {
@@ -65,6 +103,32 @@ export default defineCommand({
default: true,
description: "Open browser automatically",
},
selection: {
type: "boolean",
description: "Print the current element selected in a running Studio preview and exit",
default: false,
},
json: {
type: "boolean",
description: "Output preview selection/context as JSON (only with --selection or --context)",
default: false,
},
context: {
type: "boolean",
description:
"Print the current agent-readable context from a running Studio preview and exit",
default: false,
},
"context-fields": {
type: "string",
description:
"Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)",
},
"context-detail": {
type: "string",
description: "Context payload detail: compact or full (only with --context)",
default: "compact",
},
"browser-path": {
type: "string",
description: "Path to the browser executable to open",
@@ -80,6 +144,7 @@ export default defineCommand({
},
async run({ args }) {
const startPort = parseInt(args.port ?? "3002", 10);
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
// --list: scan and display active servers
if (args.list) {
@@ -111,6 +176,26 @@ export default defineCommand({
return;
}
if (args.context) {
const project = resolveProject(args.dir);
return printCurrentContext(project.dir, startPort, {
json: Boolean(args.json),
fields: args["context-fields"] as string | undefined,
detail: args["context-detail"] as string | undefined,
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
});
}
if (args.selection) {
const project = resolveProject(args.dir);
return printCurrentSelection(
project.dir,
startPort,
Boolean(args.json),
preferredContextPort,
);
}
// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
const orphansKilled = killOrphanedProcesses();
if (orphansKilled > 0) {
@@ -198,28 +283,390 @@ export default defineCommand({
},
});
/**
* Dev mode: spawn the studio dev server from the monorepo.
*/
async function runDevMode(
dir: string,
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
function previewBaseUrl(port: number): string {
return `http://127.0.0.1:${port}`;
}
function absolutePreviewUrl(port: number, path: string): string {
if (/^https?:\/\//.test(path)) return path;
return `${previewBaseUrl(port)}${path.startsWith("/") ? path : `/${path}`}`;
}
function hasExplicitPreviewPort(argv: string[]): boolean {
return argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
}
function printSelectionFailure(code: string, message: string, json: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
} else {
clack.log.error(message);
}
process.exitCode = 1;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function previewServerPayload(server: { port: number; projectName: string; projectDir: string }): {
port: number;
projectName: string;
projectDir: string;
url: string;
} {
return {
port: server.port,
projectName: server.projectName,
projectDir: server.projectDir,
url: previewBaseUrl(server.port),
};
}
function parseContextFields(value: string | undefined): ContextField[] {
if (value === undefined) return DEFAULT_CONTEXT_FIELDS;
if (!value.trim()) throw new Error("--context-fields cannot be empty");
const allowed = new Set<ContextField>(DEFAULT_CONTEXT_FIELDS);
const fields = value
.split(",")
.map((field) => field.trim())
.filter(Boolean);
const invalid = fields.filter((field) => !allowed.has(field as ContextField));
if (invalid.length > 0) {
throw new Error(
`Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
);
}
return [...new Set(fields)] as ContextField[];
}
function contextIncludes(fields: ContextField[], field: ContextField): boolean {
return fields.includes(field);
}
function addContextError(
payload: Record<string, unknown>,
field: ContextField,
error: { code: string; message: string },
): void {
payload.errors = {
...((payload.errors as Record<string, unknown> | undefined) ?? {}),
[field]: error,
};
}
async function printCurrentSelection(
projectDir: string,
startPort: number,
json: boolean,
preferredPort?: number,
): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
const {
AmbiguousPreviewServerError,
PreviewServerPortMismatchError,
fetchStudioSelection,
findPreviewServerForProject,
} = await import("../utils/studioSelectionClient.js");
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
try {
server = await findPreviewServerForProject(
projectDir,
startPort,
undefined,
undefined,
preferredPort === undefined ? undefined : { preferredPort },
);
} catch (err) {
if (err instanceof AmbiguousPreviewServerError) {
printSelectionFailure("ambiguous-preview-server", err.message, json);
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, json);
return;
}
throw err;
}
if (!server) {
printSelectionFailure(
"preview-not-running",
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
json,
);
return;
}
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const pName = options?.projectName ?? basename(dir);
const symlinkPath = join(projectsDir, pName);
let response: Awaited<ReturnType<typeof fetchStudioSelection>>;
try {
response = await fetchStudioSelection(server);
} catch (err) {
printSelectionFailure("selection-unavailable", errorMessage(err), json);
return;
}
if (!response.selection) {
printSelectionFailure(
"no-selection",
"Studio is running, but no element is selected. Select an element in Studio and rerun this command.",
json,
);
return;
}
const selection = {
...response.selection,
thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl),
};
if (json) {
console.log(
JSON.stringify(
{
ok: true,
server: previewServerPayload(server),
selection,
updatedAt: response.updatedAt,
},
null,
2,
),
);
return;
}
console.log(`${c.success("◇")} ${c.accent(selection.label)} selected in Studio`);
console.log(` ${c.dim("Source")} ${selection.sourceFile}`);
console.log(
` ${c.dim("Target")} ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`,
);
console.log(` ${c.dim("Time")} ${selection.currentTime.toFixed(3)}s`);
console.log(` ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`);
console.log();
console.log(c.dim("Use --json for the full agent-readable selection payload."));
}
function countLintFindings(findings: Array<{ severity: string }>): {
errors: number;
warnings: number;
} {
return {
errors: findings.filter((finding) => finding.severity === "error").length,
warnings: findings.filter((finding) => finding.severity === "warning").length,
};
}
async function printCurrentContext(
projectDir: string,
startPort: number,
options: { json: boolean; fields?: string; detail?: string; preferredPort?: number },
): Promise<void> {
let fields: ContextField[];
try {
fields = parseContextFields(options.fields);
} catch (err) {
printSelectionFailure(
"invalid-context-fields",
err instanceof Error ? err.message : String(err),
options.json,
);
return;
}
const fullDetail = options.detail === "full";
if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) {
printSelectionFailure(
"invalid-context-detail",
"--context-detail must be compact or full",
options.json,
);
return;
}
const {
AmbiguousPreviewServerError,
PreviewServerPortMismatchError,
fetchStudioLint,
fetchStudioSelection,
findPreviewServerForProject,
} = await import("../utils/studioSelectionClient.js");
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
try {
server = await findPreviewServerForProject(
projectDir,
startPort,
undefined,
undefined,
options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort },
);
} catch (err) {
if (err instanceof AmbiguousPreviewServerError) {
printSelectionFailure("ambiguous-preview-server", err.message, options.json);
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, options.json);
return;
}
throw err;
}
if (!server) {
printSelectionFailure(
"preview-not-running",
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
options.json,
);
return;
}
const wantsSelection = contextIncludes(fields, "selection");
const wantsLint = contextIncludes(fields, "lint");
const [selectionResult, lintResult] = await Promise.allSettled([
wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null),
wantsLint ? fetchStudioLint(server) : Promise.resolve(null),
]);
const selection =
selectionResult.status === "fulfilled" && selectionResult.value?.selection
? {
ok: true as const,
value: fullDetail
? {
...selectionResult.value.selection,
thumbnailUrl: absolutePreviewUrl(
server.port,
selectionResult.value.selection.thumbnailUrl,
),
}
: compactSelectionPayload({
...selectionResult.value.selection,
thumbnailUrl: absolutePreviewUrl(
server.port,
selectionResult.value.selection.thumbnailUrl,
),
}),
updatedAt: selectionResult.value.updatedAt,
}
: {
ok: false as const,
error:
selectionResult.status === "rejected"
? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) }
: {
code: "no-selection",
message: "Studio is running, but no element is selected.",
},
};
const lint =
lintResult.status === "fulfilled" && lintResult.value
? {
ok: true as const,
summary: countLintFindings(lintResult.value.findings),
findings: lintResult.value.findings,
}
: {
ok: false as const,
error:
lintResult.status === "rejected"
? { code: "lint-unavailable", message: errorMessage(lintResult.reason) }
: { code: "lint-not-requested", message: "Lint was not requested." },
};
const payload: Record<string, unknown> = { ok: true };
if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server);
if (contextIncludes(fields, "selection")) {
payload.selection = selection.ok ? selection.value : null;
payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null;
if (!selection.ok) addContextError(payload, "selection", selection.error);
}
if (contextIncludes(fields, "lint")) payload.lint = lint;
if (contextIncludes(fields, "capabilities")) {
payload.capabilities = {
selection: true,
lint: true,
frame: false,
visibleElements: false,
lastAction: false,
};
}
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
return;
}
console.log(`${c.success("◇")} Studio context`);
if (contextIncludes(fields, "server")) {
console.log(` ${c.dim("Project")} ${server.projectName}`);
console.log(` ${c.dim("Studio")} ${previewBaseUrl(server.port)}`);
}
if (contextIncludes(fields, "selection")) {
if (selection.ok) {
console.log(` ${c.dim("Selection")} ${selection.value.label}`);
} else {
console.log(` ${c.dim("Selection")} ${selection.error.message}`);
}
}
if (contextIncludes(fields, "lint")) {
if (lint.ok) {
console.log(
` ${c.dim("Lint")} ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`,
);
} else {
console.log(` ${c.dim("Lint")} ${lint.error.message}`);
}
}
console.log();
console.log(c.dim("Use --json for the full agent-readable context payload."));
}
function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload {
return {
schemaVersion: selection.schemaVersion,
projectId: selection.projectId,
compositionPath: selection.compositionPath,
sourceFile: selection.sourceFile,
currentTime: selection.currentTime,
target: selection.target,
label: selection.label,
tagName: selection.tagName,
boundingBox: selection.boundingBox,
textContent: selection.textContent,
thumbnailUrl: selection.thumbnailUrl,
};
}
function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
if (options?.noOpen) return;
openBrowser(`${url}#project/${projectName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
function printStudioSummary(
projectName: string,
url: string,
opts: { details?: string[]; footer?: string } = {},
): void {
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
for (const detail of opts.details ?? []) {
console.log(` ${c.dim(detail)}`);
}
if (opts.details?.length && opts.footer) console.log();
if (opts.footer) console.log(` ${c.dim(opts.footer)}`);
console.log();
}
function linkProjectIntoStudioData(
dir: string,
projectsDir: string,
projectName: string,
): { symlinkPath: string; createdSymlink: boolean } {
const symlinkPath = join(projectsDir, projectName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
@@ -227,24 +674,88 @@ async function runDevMode(
if (existsSync(symlinkPath)) {
try {
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
const target = readlinkSync(symlinkPath);
if (resolve(target) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
// If it's a real directory, leave it alone
} catch {
// Not a symlink — don't touch it
// Real directories or unreadable paths are left untouched.
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
return { symlinkPath, createdSymlink };
}
function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void {
if (!createdSymlink) return;
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
function registerChildTreeShutdown(child: StudioChildProcess): void {
const shutdown = (): void => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
function waitForChildClose(child: StudioChildProcess): Promise<void> {
return new Promise<void>((resolveClose) => {
child.on("close", () => resolveClose());
});
}
function attachStudioReadyHandler(
child: StudioChildProcess,
spinner: ReturnType<typeof clack.spinner>,
projectName: string,
options?: BrowserLaunchOptions,
): void {
let detected = false;
function handleOutput(data: Buffer): void {
const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1];
if (!url || detected) return;
detected = true;
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
openStudioBrowser(url, projectName, options);
child.stdout.removeListener("data", handleOutput);
child.stderr.removeListener("data", handleOutput);
}
child.stdout.on("data", handleOutput);
child.stderr.on("data", handleOutput);
child.on("error", (err) => {
spinner.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
}
/**
* Dev mode: spawn the studio dev server from the monorepo.
*/
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const pName = options?.projectName ?? basename(dir);
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
clack.intro(c.bold("hyperframes preview"));
const s = clack.spinner();
@@ -257,55 +768,8 @@ async function runDevMode(
stdio: ["ignore", "pipe", "pipe"],
});
let frontendUrl = "";
function handleOutput(data: Buffer): void {
const text = data.toString();
// Detect Vite URL
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !frontendUrl) {
frontendUrl = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
const urlToOpen = `${frontendUrl}#project/${pName}`;
openBrowser(urlToOpen, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
child.stdout?.removeListener("data", handleOutput);
child.stderr?.removeListener("data", handleOutput);
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
// If child exits before we detect readiness, show what we have
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
if (createdSymlink) {
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
attachStudioReadyHandler(child, s, pName, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
// SIGINT to the foreground process group (covers the common case), but
@@ -313,15 +777,8 @@ async function runDevMode(
// would survive without explicit cleanup.
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
// propagates via the console process group instead.
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
registerChildTreeShutdown(child);
return waitForChildClose(child);
}
/**
@@ -341,37 +798,14 @@ function hasLocalStudio(dir: string): boolean {
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
* Provides full Vite HMR and the complete studio experience.
*/
async function runLocalStudioMode(
dir: string,
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
): Promise<void> {
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
const req = createRequire(join(dir, "package.json"));
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
const pName = options?.projectName ?? basename(dir);
// Symlink project into studio's data directory
const projectsDir = join(studioPkgPath, "data", "projects");
const symlinkPath = join(projectsDir, pName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
if (dir !== symlinkPath) {
if (existsSync(symlinkPath) && lstatSync(symlinkPath).isSymbolicLink()) {
if (resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)"));
const s = clack.spinner();
@@ -383,58 +817,12 @@ async function runLocalStudioMode(
stdio: ["ignore", "pipe", "pipe"],
});
let detected = false;
function handleOutput(data: Buffer): void {
const text = data.toString();
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !detected) {
detected = true;
const url = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
if (createdSymlink) {
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
attachStudioReadyHandler(child, s, pName, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
registerChildTreeShutdown(child);
return waitForChildClose(child);
}
/**
@@ -447,14 +835,7 @@ async function runLocalStudioMode(
async function runEmbeddedMode(
dir: string,
startPort: number,
options?: {
projectName?: string;
forceNew?: boolean;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
options?: EmbeddedStudioOptions,
): Promise<void> {
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
await import("../server/studioServer.js");
@@ -504,21 +885,10 @@ async function runEmbeddedMode(
if (result.type === "already-running") {
const url = `http://localhost:${result.port}`;
s.stop(c.success("Already running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(
` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`,
);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
printStudioSummary(pName, url, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
openStudioBrowser(url, pName, options);
return;
}
@@ -529,21 +899,14 @@ async function runEmbeddedMode(
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
console.log();
}
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Edit with your AI agent — it has HyperFrames skills installed.")}`);
console.log(` ${c.dim("Changes reload automatically in the studio.")}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
printStudioSummary(pName, url, {
details: [
"Edit with your AI agent — it has HyperFrames skills installed.",
"Changes reload automatically in the studio.",
],
footer: "Press Ctrl+C to stop",
});
openStudioBrowser(url, pName, options);
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
// HTTP server keeps handles open, so the event loop stays alive after the
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest";
import { resolve } from "node:path";
import {
AmbiguousPreviewServerError,
fetchStudioLint,
fetchStudioSelection,
studioApiUrl,
findPreviewServerForProject,
PreviewServerPortMismatchError,
studioSelectionUrl,
} from "./studioSelectionClient";
import type { ActiveServer } from "../server/portUtils";
const servers: ActiveServer[] = [
{
port: 3002,
projectName: "other",
projectDir: "/tmp/other",
version: "0.7.17",
pid: null,
},
{
port: 3003,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "0.7.17",
pid: "123",
},
];
function mockProjectsFetch(port = 5190): typeof fetch {
return vi.fn(async (url: string | URL | Request) => {
expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`);
return new Response(
JSON.stringify({
projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
}
describe("studioSelectionClient", () => {
it("finds the active preview server for a project directory", async () => {
const scan = vi.fn(async () => servers);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan);
expect(server?.port).toBe(3003);
expect(scan).toHaveBeenCalledWith(3002);
});
it("matches by project directory when multiple projects are open", async () => {
const scan = vi.fn(async () => [
...servers,
{
port: 3004,
projectName: "third",
projectDir: "/tmp/third",
version: "0.7.17",
pid: null,
},
]);
const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan);
expect(server?.port).toBe(3004);
});
it("rejects ambiguous duplicate servers for the same project", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan),
).rejects.toMatchObject({
name: "AmbiguousPreviewServerError",
ports: [3003, 3004],
} satisfies Partial<AmbiguousPreviewServerError>);
});
it("uses an explicit preferred port to disambiguate duplicate project servers", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, {
preferredPort: 3004,
});
expect(server?.port).toBe(3004);
});
it("rejects an explicit preferred port that does not match the only project server", async () => {
const scan = vi.fn(async () => [servers[1]!]);
const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 }));
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 3999,
}),
).rejects.toMatchObject({
name: "PreviewServerPortMismatchError",
requestedPort: 3999,
ports: [3003],
} satisfies Partial<PreviewServerPortMismatchError>);
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects");
});
it("falls back to Vite Studio project discovery on port 5190", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch();
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
expect(server).toEqual({
port: 5190,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "studio-dev",
pid: null,
});
});
it("checks an explicit preferred port for Vite Studio discovery", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch(5191);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 5191,
});
expect(server?.port).toBe(5191);
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
});
it("builds a URL to the existing preview server's selection endpoint", () => {
expect(studioSelectionUrl(servers[1]!)).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/selection",
);
});
it("builds URLs to other preview server API routes", () => {
expect(studioApiUrl(servers[1]!, "lint")).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/lint",
);
});
it("fetches the current selection snapshot from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
selection: {
schemaVersion: 1,
projectId: "demo project",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 2,
target: { hfId: "cta" },
label: "CTA",
tagName: "button",
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
textContent: "Go",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: { canSelect: true },
thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png",
},
updatedAt: "2026-06-28T16:00:00.000Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioSelection(servers[1]!, fetchImpl);
expect(result.selection?.target.hfId).toBe("cta");
expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z");
expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!));
});
it("throws when the preview server returns a failed response", async () => {
await expect(
fetchStudioSelection(
servers[1]!,
vi.fn(async () => new Response("missing", { status: 404 })),
),
).rejects.toThrow("selection endpoint returned 404");
});
it("fetches lint findings from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioLint(servers[1]!, fetchImpl);
expect(result.findings).toHaveLength(1);
expect(result.findings[0]?.message).toBe("Missing timeline");
expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint"));
});
});
@@ -0,0 +1,149 @@
import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type {
LintResult,
ResolvedProject,
StudioSelectionResponse,
} from "@hyperframes/studio-server";
export type StudioLintResponse = LintResult;
const VITE_STUDIO_DISCOVERY_PORTS = [5190] as const;
interface StudioProjectsResponse {
projects?: ResolvedProject[];
}
interface FindPreviewServerOptions {
preferredPort?: number;
}
export class AmbiguousPreviewServerError extends Error {
readonly ports: number[];
constructor(servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`Multiple Studio preview servers match this project (${ports.join(", ")}). Pass --port <port> to choose one.`,
);
this.name = "AmbiguousPreviewServerError";
this.ports = ports;
}
}
export class PreviewServerPortMismatchError extends Error {
readonly requestedPort: number;
readonly ports: number[];
constructor(requestedPort: number, servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.`,
);
this.name = "PreviewServerPortMismatchError";
this.requestedPort = requestedPort;
this.ports = ports;
}
}
function normalizePath(path: string): string {
const resolved = resolve(path);
try {
if (existsSync(resolved)) {
return realpathSync(resolved).replace(/\\/g, "/").toLowerCase();
}
} catch {
// Fall through to resolved-path normalization.
}
return resolved.replace(/\\/g, "/").toLowerCase();
}
export async function findPreviewServerForProject(
projectDir: string,
startPort = 3002,
scan: (startPort?: number) => Promise<ActiveServer[]> = scanActiveServers,
fetchImpl: typeof fetch = fetch,
options: FindPreviewServerOptions = {},
): Promise<ActiveServer | null> {
const normalizedProjectDir = normalizePath(projectDir);
const servers = await scan(startPort);
const embeddedServers = servers.filter(
(server) => normalizePath(server.projectDir) === normalizedProjectDir,
);
if (options.preferredPort !== undefined) {
const preferred = embeddedServers.find((server) => server.port === options.preferredPort);
if (preferred) return preferred;
const viteServer = await findViteStudioServerForProject(normalizedProjectDir, fetchImpl, [
options.preferredPort,
]);
if (viteServer) return viteServer;
if (embeddedServers.length > 0) {
throw new PreviewServerPortMismatchError(options.preferredPort, embeddedServers);
}
return null;
}
if (embeddedServers.length === 1) return embeddedServers[0]!;
if (embeddedServers.length > 1) throw new AmbiguousPreviewServerError(embeddedServers);
return findViteStudioServerForProject(normalizedProjectDir, fetchImpl);
}
export function studioSelectionUrl(server: ActiveServer): string {
return studioApiUrl(server, "selection");
}
export function studioApiUrl(server: ActiveServer, route: string): string {
return `http://127.0.0.1:${server.port}/api/projects/${encodeURIComponent(server.projectName)}/${route}`;
}
async function findViteStudioServerForProject(
normalizedProjectDir: string,
fetchImpl: typeof fetch,
ports: readonly number[] = VITE_STUDIO_DISCOVERY_PORTS,
): Promise<ActiveServer | null> {
for (const port of ports) {
try {
const response = await fetchImpl(`http://127.0.0.1:${port}/api/projects`);
if (!response.ok) continue;
const payload = (await response.json()) as StudioProjectsResponse;
const project = payload.projects?.find(
(candidate) => normalizePath(candidate.dir) === normalizedProjectDir,
);
if (!project) continue;
return {
port,
projectName: project.id,
projectDir: project.dir,
version: "studio-dev",
pid: null,
};
} catch {
// Port is not a Vite-served Studio, or the dev server is not reachable.
}
}
return null;
}
export async function fetchStudioSelection(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioSelectionResponse> {
const url = studioSelectionUrl(server);
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`selection endpoint returned ${response.status}`);
}
return (await response.json()) as StudioSelectionResponse;
}
export async function fetchStudioLint(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioLintResponse> {
const url = studioApiUrl(server, "lint");
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`lint endpoint returned ${response.status}`);
}
return (await response.json()) as StudioLintResponse;
}