Skip to content

Commit 2ec0062

Browse files
fix(cli): validate project directory before starting preview (#1394)
Preview previously started Studio even when the path was invalid (e.g. `hyperframes preview #`), yielding an empty project view. Align preview with lint/render by resolving the project up front, and add a clearer error when `#` is passed as a directory argument. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7fa3696 commit 2ec0062

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

packages/cli/src/commands/preview.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type FindPortResult,
3838
} from "../server/portUtils.js";
3939
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
40+
import { resolveProject } from "../utils/project.js";
4041

4142
export default defineCommand({
4243
meta: { name: "preview", description: "Start the studio for previewing compositions" },
@@ -118,23 +119,18 @@ export default defineCommand({
118119
}
119120

120121
const rawArg = args.dir;
121-
const dir = resolve(rawArg ?? ".");
122-
123-
// Compute display name: preserve symlink/CWD name when user runs "hyperframes preview ."
124122
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
125-
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir);
123+
const project = resolveProject(rawArg);
124+
const dir = project.dir;
125+
const indexPath = project.indexPath;
126+
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name;
126127

127128
// Lint before starting — surface issues for the agent to fix.
128-
// preview.ts doesn't use resolveProject() because it needs to proceed even without index.html.
129-
const indexPath = join(dir, "index.html");
130-
if (existsSync(indexPath)) {
131-
const project = { dir, name: projectName, indexPath };
132-
const lintResult = await lintProject(project);
133-
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
134-
console.log();
135-
for (const line of formatLintFindings(lintResult)) console.log(line);
136-
console.log();
137-
}
129+
const lintResult = await lintProject({ dir, name: projectName, indexPath });
130+
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
131+
console.log();
132+
for (const line of formatLintFindings(lintResult)) console.log(line);
133+
console.log();
138134
}
139135

140136
// Validation: --user-data-dir requires --browser-path
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join, basename } from "node:path";
5+
import { InvalidProjectError, resolveProjectOrThrow } from "./project.js";
6+
7+
describe("resolveProjectOrThrow", () => {
8+
it("rejects # as a project directory with a helpful message", () => {
9+
try {
10+
resolveProjectOrThrow("#");
11+
expect.unreachable("expected InvalidProjectError");
12+
} catch (err) {
13+
expect(err).toBeInstanceOf(InvalidProjectError);
14+
const error = err as InvalidProjectError;
15+
expect(error.title).toBe("Invalid project directory: #");
16+
expect(error.hint).toContain("URL fragment");
17+
expect(error.suggestion).toContain("hyperframes preview .");
18+
}
19+
});
20+
21+
it("rejects a missing directory", () => {
22+
const missing = join(tmpdir(), `hf-missing-${Date.now()}`);
23+
expect(() => resolveProjectOrThrow(missing)).toThrowError(/Not a directory/);
24+
});
25+
26+
it("rejects a directory without index.html", () => {
27+
const dir = mkdtempSync(join(tmpdir(), "hf-empty-project-"));
28+
try {
29+
expect(() => resolveProjectOrThrow(dir)).toThrowError(/No composition found/);
30+
} finally {
31+
rmSync(dir, { recursive: true, force: true });
32+
}
33+
});
34+
35+
it("accepts a directory with index.html", () => {
36+
const dir = mkdtempSync(join(tmpdir(), "hf-valid-project-"));
37+
try {
38+
writeFileSync(join(dir, "index.html"), '<html data-composition-id="test"></html>');
39+
const project = resolveProjectOrThrow(dir);
40+
expect(project.dir).toBe(dir);
41+
expect(project.indexPath).toBe(join(dir, "index.html"));
42+
expect(project.name).toBe(basename(dir));
43+
} finally {
44+
rmSync(dir, { recursive: true, force: true });
45+
}
46+
});
47+
});

packages/cli/src/utils/project.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,56 @@ export interface ProjectDir {
88
indexPath: string;
99
}
1010

11-
export function resolveProject(dirArg: string | undefined): ProjectDir {
11+
export class InvalidProjectError extends Error {
12+
readonly title: string;
13+
readonly hint?: string;
14+
readonly suggestion?: string;
15+
16+
constructor(title: string, hint?: string, suggestion?: string) {
17+
super(title);
18+
this.name = "InvalidProjectError";
19+
this.title = title;
20+
this.hint = hint;
21+
this.suggestion = suggestion;
22+
}
23+
}
24+
25+
export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
26+
const trimmed = dirArg?.trim();
27+
if (trimmed === "#") {
28+
throw new InvalidProjectError(
29+
"Invalid project directory: #",
30+
"# is a URL fragment, not a project path.",
31+
"Run hyperframes preview . from your project directory.",
32+
);
33+
}
34+
1235
const dir = resolve(dirArg ?? ".");
1336
const name = basename(dir);
1437
const indexPath = resolve(dir, "index.html");
1538

1639
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
17-
errorBox("Not a directory: " + dir);
18-
process.exit(1);
40+
throw new InvalidProjectError("Not a directory: " + dir);
1941
}
2042
if (!existsSync(indexPath)) {
21-
errorBox(
43+
throw new InvalidProjectError(
2244
"No composition found in " + dir,
2345
"No index.html file found.",
2446
"Run npx hyperframes init to create a new composition.",
2547
);
26-
process.exit(1);
2748
}
2849

2950
return { dir, name, indexPath };
3051
}
52+
53+
export function resolveProject(dirArg: string | undefined): ProjectDir {
54+
try {
55+
return resolveProjectOrThrow(dirArg);
56+
} catch (err) {
57+
if (err instanceof InvalidProjectError) {
58+
errorBox(err.title, err.hint, err.suggestion);
59+
process.exit(1);
60+
}
61+
throw err;
62+
}
63+
}

0 commit comments

Comments
 (0)