diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 67b9ce44f..19d855701 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -37,6 +37,7 @@ import { type FindPortResult, } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; +import { resolveProject } from "../utils/project.js"; export default defineCommand({ meta: { name: "preview", description: "Start the studio for previewing compositions" }, @@ -118,23 +119,18 @@ export default defineCommand({ } const rawArg = args.dir; - const dir = resolve(rawArg ?? "."); - - // Compute display name: preserve symlink/CWD name when user runs "hyperframes preview ." const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir); + const project = resolveProject(rawArg); + const dir = project.dir; + const indexPath = project.indexPath; + const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. - // preview.ts doesn't use resolveProject() because it needs to proceed even without index.html. - const indexPath = join(dir, "index.html"); - if (existsSync(indexPath)) { - const project = { dir, name: projectName, indexPath }; - const lintResult = await lintProject(project); - if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { - console.log(); - for (const line of formatLintFindings(lintResult)) console.log(line); - console.log(); - } + const lintResult = await lintProject({ dir, name: projectName, indexPath }); + if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + console.log(); + for (const line of formatLintFindings(lintResult)) console.log(line); + console.log(); } // Validation: --user-data-dir requires --browser-path diff --git a/packages/cli/src/utils/project.test.ts b/packages/cli/src/utils/project.test.ts new file mode 100644 index 000000000..7aadcd48e --- /dev/null +++ b/packages/cli/src/utils/project.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import { InvalidProjectError, resolveProjectOrThrow } from "./project.js"; + +describe("resolveProjectOrThrow", () => { + it("rejects # as a project directory with a helpful message", () => { + try { + resolveProjectOrThrow("#"); + expect.unreachable("expected InvalidProjectError"); + } catch (err) { + expect(err).toBeInstanceOf(InvalidProjectError); + const error = err as InvalidProjectError; + expect(error.title).toBe("Invalid project directory: #"); + expect(error.hint).toContain("URL fragment"); + expect(error.suggestion).toContain("hyperframes preview ."); + } + }); + + it("rejects a missing directory", () => { + const missing = join(tmpdir(), `hf-missing-${Date.now()}`); + expect(() => resolveProjectOrThrow(missing)).toThrowError(/Not a directory/); + }); + + it("rejects a directory without index.html", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-empty-project-")); + try { + expect(() => resolveProjectOrThrow(dir)).toThrowError(/No composition found/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("accepts a directory with index.html", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-valid-project-")); + try { + writeFileSync(join(dir, "index.html"), ''); + const project = resolveProjectOrThrow(dir); + expect(project.dir).toBe(dir); + expect(project.indexPath).toBe(join(dir, "index.html")); + expect(project.name).toBe(basename(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/utils/project.ts b/packages/cli/src/utils/project.ts index cf701a8e1..70681019d 100644 --- a/packages/cli/src/utils/project.ts +++ b/packages/cli/src/utils/project.ts @@ -8,23 +8,56 @@ export interface ProjectDir { indexPath: string; } -export function resolveProject(dirArg: string | undefined): ProjectDir { +export class InvalidProjectError extends Error { + readonly title: string; + readonly hint?: string; + readonly suggestion?: string; + + constructor(title: string, hint?: string, suggestion?: string) { + super(title); + this.name = "InvalidProjectError"; + this.title = title; + this.hint = hint; + this.suggestion = suggestion; + } +} + +export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir { + const trimmed = dirArg?.trim(); + if (trimmed === "#") { + throw new InvalidProjectError( + "Invalid project directory: #", + "# is a URL fragment, not a project path.", + "Run hyperframes preview . from your project directory.", + ); + } + const dir = resolve(dirArg ?? "."); const name = basename(dir); const indexPath = resolve(dir, "index.html"); if (!existsSync(dir) || !statSync(dir).isDirectory()) { - errorBox("Not a directory: " + dir); - process.exit(1); + throw new InvalidProjectError("Not a directory: " + dir); } if (!existsSync(indexPath)) { - errorBox( + throw new InvalidProjectError( "No composition found in " + dir, "No index.html file found.", "Run npx hyperframes init to create a new composition.", ); - process.exit(1); } return { dir, name, indexPath }; } + +export function resolveProject(dirArg: string | undefined): ProjectDir { + try { + return resolveProjectOrThrow(dirArg); + } catch (err) { + if (err instanceof InvalidProjectError) { + errorBox(err.title, err.hint, err.suggestion); + process.exit(1); + } + throw err; + } +}