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
@@ -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;
}