Skip to content
Closed
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
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@
"scripts": {
"dev": "pnpm studio",
"build": "pnpm -r build",
"build:producer": "pnpm --filter @hyperframes/producer build",
"build:hyperframes-runtime": "pnpm --filter @hyperframes/core build:hyperframes-runtime",
"build:hyperframes-runtime:modular": "pnpm --filter @hyperframes/core build:hyperframes-runtime:modular",
"studio": "concurrently \"pnpm --filter @hyperframes/studio-backend dev\" \"pnpm --filter @hyperframes/studio-frontend dev\"",
"studio-backend": "pnpm --filter @hyperframes/studio-backend dev",
"studio-frontend": "pnpm --filter @hyperframes/studio-frontend dev"
"studio": "pnpm --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "pnpm --filter @hyperframes/core build:hyperframes-runtime"
},
"devDependencies": {
"@types/node": "^25.0.10",
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"scripts": {
"dev": "tsx src/cli.ts",
"build": "pnpm build:studio && tsup && pnpm build:runtime && pnpm build:copy",
"build:studio": "cd ../studio/frontend && pnpm build",
"build:studio": "cd ../studio && pnpm build",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates && cp -r ../studio/frontend/dist/* dist/studio/ && cp -r src/templates/warm-grain src/templates/play-mode src/templates/swiss-grid src/templates/vignelli dist/templates/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/warm-grain src/templates/play-mode src/templates/swiss-grid src/templates/vignelli dist/templates/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand All @@ -31,7 +31,6 @@
},
"devDependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/studio-backend": "workspace:*",
"@clack/prompts": "^1.1.0",
"@hono/node-server": "^1.0.0",
"@hyperframes/engine": "workspace:*",
Expand Down
208 changes: 23 additions & 185 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import {
unlinkSync,
readlinkSync,
mkdirSync,
readFileSync,
} from "node:fs";
import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { MIME_TYPES } from "../utils/mime.js";

/**
* Check if a port is available by trying to listen on it briefly.
Expand Down Expand Up @@ -73,12 +71,12 @@ export default defineCommand({
* Dev mode: spawn pnpm studio from the monorepo (existing behavior).
*/
async function runDevMode(dir: string): Promise<void> {
// Find monorepo root by navigating from cli/ package
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..");
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");

// Symlink project into studio's data directory so it appears in the project list
const projectsDir = join(repoRoot, "studio", "backend", "data", "projects");
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const projectName = basename(dir);
const symlinkPath = join(projectsDir, projectName);

Expand All @@ -88,10 +86,14 @@ async function runDevMode(dir: string): Promise<void> {
if (dir !== symlinkPath) {
if (existsSync(symlinkPath)) {
try {
const target = readlinkSync(symlinkPath);
if (resolve(target) !== dir) {
unlinkSync(symlinkPath);
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
const target = readlinkSync(symlinkPath);
if (resolve(target) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
}
// If it's a real directory, leave it alone
} catch {
// Not a symlink — don't touch it
}
Expand All @@ -108,47 +110,33 @@ async function runDevMode(dir: string): Promise<void> {
const s = clack.spinner();
s.start("Starting studio...");

// Pipe child output so we can parse it and show clean output
const child = spawn("pnpm", ["studio"], {
cwd: repoRoot,
// Run the new consolidated studio (single Vite dev server with API plugin)
const studioPkgDir = join(repoRoot, "packages", "studio");
const child = spawn("pnpm", ["exec", "vite"], {
cwd: studioPkgDir,
stdio: ["ignore", "pipe", "pipe"],
});

let backendReady = false;
let frontendUrl = "";

function handleOutput(data: Buffer): void {
const text = data.toString();

// Detect backend ready
if (!backendReady && text.includes("Studio backend running")) {
backendReady = true;
}

// Detect frontend URL (Vite may pick a different port)
// Detect Vite URL
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch) {
if (localMatch && !frontendUrl) {
frontendUrl = localMatch[1] ?? "";
}

// Once both are ready, show the clean output
if (backendReady && frontendUrl) {
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Backend")} ${c.accent("http://localhost:3002")}`);
console.log(` ${c.dim("Frontend")} ${c.accent(frontendUrl)}`);
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();

// Open browser — capture URL before clearing state
const urlToOpen = `${frontendUrl}#/project/${projectName}`;
import("open").then((mod) => mod.default(urlToOpen)).catch(() => {});

// Stop listening — we don't need to parse anymore
backendReady = false;
frontendUrl = "";
child.stdout?.removeListener("data", handleOutput);
child.stderr?.removeListener("data", handleOutput);
}
Expand Down Expand Up @@ -189,160 +177,10 @@ async function runDevMode(dir: string): Promise<void> {
}

/**
* Embedded mode: start an inline Hono server with the studio backend routes
* and serve the pre-built frontend from dist/studio/.
* Embedded mode — not yet available.
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
*/
async function runEmbeddedMode(dir: string, port: number): Promise<void> {
const projectName = basename(dir);

// Resolve the studio frontend dist directory relative to the CLI bundle
const thisFile = fileURLToPath(import.meta.url);
const studioDir = join(dirname(thisFile), "studio");

if (!existsSync(studioDir)) {
console.error(
c.error(
`Studio frontend not found at ${studioDir}. Did you run 'pnpm build'?`,
),
);
process.exit(1);
}

// Set up data directories and env vars BEFORE importing the studio backend
// routes, since the route modules read these env vars at initialization time.
const dataDir = join(dirname(thisFile), "data", "projects");
mkdirSync(dataDir, { recursive: true });
process.env.STUDIO_DATA_DIR = dataDir;

const rendersDir = join(dirname(thisFile), "data", "renders");
mkdirSync(rendersDir, { recursive: true });
process.env.STUDIO_RENDERS_DIR = rendersDir;

// Import after env vars are set so the route modules pick up the correct paths
const { serve } = await import("@hono/node-server");
const { createEmbeddedApp } = await import(
"@hyperframes/studio-backend/embedded"
);

// Symlink the project into the data directory
const symlinkPath = join(dataDir, projectName);
let createdSymlink = false;

if (dir !== symlinkPath) {
// Check if something already exists at the symlink path
let needsCreate = true;
try {
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
const target = readlinkSync(symlinkPath);
if (resolve(target) === resolve(dir)) {
needsCreate = false; // Already points to the right place
} else {
unlinkSync(symlinkPath); // Points elsewhere, replace it
}
}
// If it's a real directory, leave it alone
if (stat.isDirectory() && !stat.isSymbolicLink()) {
needsCreate = false;
}
} catch {
// Nothing at that path — good, we'll create it
}

if (needsCreate) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}

// Create the Hono app with all studio backend routes.
// The factory uses the same Hono import as the routes, avoiding class
// mismatches when tsup bundles multiple copies of the Hono module.
const app = createEmbeddedApp();

// port is passed as parameter from findAvailablePort()

// Static file serving: use Hono's notFound handler for SPA fallback
// and register explicit static asset routes.
function serveStaticFile(urlPath: string): Response | null {
const relativePath = urlPath.replace(/^\//, "");
const filePath = resolve(studioDir, relativePath);
if (!filePath.startsWith(resolve(studioDir) + "/")) return null;
if (!existsSync(filePath)) return null;

const content = readFileSync(filePath);
const ext = filePath.split(".").pop() ?? "";
const contentType = MIME_TYPES["." + ext] ?? "application/octet-stream";
return new Response(content, {
headers: { "Content-Type": contentType },
});
}

// Catch-all: serve static files, then SPA fallback for non-API routes
app.notFound((ctx) => {
// Try to serve a static file from the studio frontend directory
const urlPath = ctx.req.path === "/" ? "/index.html" : ctx.req.path;
const staticResponse = serveStaticFile(urlPath);
if (staticResponse) return staticResponse;

// SPA fallback for non-API routes
if (!ctx.req.path.startsWith("/api/")) {
const indexPath = join(studioDir, "index.html");
if (existsSync(indexPath)) {
return ctx.html(readFileSync(indexPath, "utf-8"));
}
}

return ctx.text("Not found", 404);
});

clack.intro(c.bold("hyperframes dev"));

const s = clack.spinner();
s.start("Starting embedded studio...");

const server = serve({
fetch: app.fetch,
port,
});

const studioUrl = `http://localhost:${port}`;

s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(studioUrl)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();

// Open browser (skip if HYPERFRAMES_NO_OPEN is set, useful for testing)
if (!process.env.HYPERFRAMES_NO_OPEN) {
const urlToOpen = `${studioUrl}/#/project/${projectName}`;
import("open")
.then((mod) => mod.default(urlToOpen))
.catch(() => {});
}

// Wait for SIGINT to shut down
return new Promise<void>((resolvePromise) => {
function cleanup(): void {
if (createdSymlink && existsSync(symlinkPath)) {
try {
unlinkSync(symlinkPath);
} catch {
/* ignore */
}
}
}

process.on("SIGINT", () => {
console.log();
console.log(c.dim(" Shutting down..."));
server.close(() => {
cleanup();
resolvePromise();
});
});
});
async function runEmbeddedMode(_dir: string, _port: number): Promise<void> {
console.error(c.error("Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>"));
process.exit(1);
}