Skip to content
Merged
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
24 changes: 10 additions & 14 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/utils/project.test.ts
Original file line number Diff line number Diff line change
@@ -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"), '<html data-composition-id="test"></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 });
}
});
});
43 changes: 38 additions & 5 deletions packages/cli/src/utils/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading