Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,25 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
2. **Local studio mode** — if `@hyperframes/studio` is installed in your project's `node_modules`, spawns Vite with full HMR for faster iteration.
3. **Monorepo mode** — if running from the Hyperframes source repo, spawns the studio dev server directly.

### `publish`

Upload the project and get back a stable `hyperframes.dev` URL:

```bash
npx hyperframes publish [dir]
npx hyperframes publish --yes
```

| Flag | Description |
|------|-------------|
| `--yes` | Skip the confirmation prompt |

`publish` zips the current project, uploads it to the HyperFrames publish backend, and prints a stable `hyperframes.dev` URL for that stored project.

The printed URL already includes the claim token, so opening it on `hyperframes.dev` lets the intended user claim the uploaded project and continue editing in the web app.

This flow does not keep a local preview server alive and does not open a tunnel. The published URL resolves to the persisted project stored by HeyGen, so it keeps working after the CLI process exits.

### `lint`

Check a composition for common issues:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const subCommands = {
catalog: () => import("./commands/catalog.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { basename, resolve } from "node:path";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineCommand } from "citty";
import * as clack from "@clack/prompts";

import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { publishProjectArchive } from "../utils/publishProject.js";

export const examples: Example[] = [
["Publish the current project with a public URL", "hyperframes publish"],
["Publish a specific directory", "hyperframes publish ./my-video"],
["Skip the consent prompt (scripts)", "hyperframes publish --yes"],
];

export default defineCommand({
meta: {
name: "publish",
description: "Upload the project and return a stable public URL",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
yes: {
type: "boolean",
alias: "y",
description: "Skip the publish confirmation prompt",
default: false,
},
},
async run({ args }) {
const rawArg = args.dir;
const dir = resolve(rawArg ?? ".");
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir);

const indexPath = join(dir, "index.html");
if (existsSync(indexPath)) {
const lintResult = 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();
}
}

if (args.yes !== true) {
console.log();
console.log(
` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`,
);
console.log(
` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`,
);
console.log();
const approved = await clack.confirm({ message: "Publish this project?" });
if (clack.isCancel(approved) || approved !== true) {
console.log();
console.log(` ${c.dim("Aborted.")}`);
console.log();
return;
}
}

clack.intro(c.bold("hyperframes publish"));
const publishSpinner = clack.spinner();
publishSpinner.start("Uploading project...");

try {
const published = await publishProjectArchive(dir);
const claimUrl = new URL(published.url);
claimUrl.searchParams.set("claim_token", published.claimToken);
publishSpinner.stop(c.success("Project published"));

console.log();
console.log(` ${c.dim("Project")} ${c.accent(published.title)}`);
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
console.log();
console.log(
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
);
console.log();
return;
} catch (err: unknown) {
publishSpinner.stop(c.error("Publish failed"));
console.error();
console.error(` ${(err as Error).message}`);
console.error();
process.exitCode = 1;
return;
}
},
});
2 changes: 2 additions & 0 deletions packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const GROUPS: Group[] = [
["capture", "Capture a website for video production"],
["catalog", "Browse and install blocks and components"],
["preview", "Start the studio for previewing compositions"],
["publish", "Upload a project and get a stable public URL"],
["render", "Render a composition to MP4 or WebM"],
],
},
Expand Down Expand Up @@ -72,6 +73,7 @@ import type { Example } from "./commands/_examples.js";
const ROOT_EXAMPLES: Example[] = [
["Create a new project", "hyperframes init my-video"],
["Start the live preview studio", "hyperframes preview"],
["Publish to hyperframes.dev", "hyperframes publish"],
["Render to MP4", "hyperframes render -o out.mp4"],
["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"],
["Validate your composition", "hyperframes lint"],
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/utils/publishProject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
createPublishArchive,
getPublishApiBaseUrl,
publishProjectArchive,
} from "./publishProject.js";

function makeProjectDir(): string {
return mkdtempSync(join(tmpdir(), "hf-publish-"));
}

describe("createPublishArchive", () => {
it("packages the project and skips hidden files and node_modules", () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
mkdirSync(join(dir, "assets"));
writeFileSync(join(dir, "assets/logo.svg"), "<svg />", "utf-8");
mkdirSync(join(dir, ".git"));
writeFileSync(join(dir, ".env"), "SECRET=1", "utf-8");
mkdirSync(join(dir, "node_modules"));
writeFileSync(join(dir, "node_modules/ignored.js"), "console.log('ignore')", "utf-8");

const archive = createPublishArchive(dir);

expect(archive.fileCount).toBe(2);
expect(archive.buffer.byteLength).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("publishProjectArchive", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
project_id: "hfp_123",
title: "demo",
file_count: 2,
url: "https://hyperframes.dev/p/hfp_123",
claim_token: "claim-token",
},
}),
{ status: 200 },
),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("uploads the archive and returns the stable project URL", async () => {
const dir = makeProjectDir();
try {
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");

const result = await publishProjectArchive(dir);

expect(getPublishApiBaseUrl()).toBe("https://api2.heygen.com");
expect(result).toMatchObject({
projectId: "hfp_123",
url: "https://hyperframes.dev/p/hfp_123",
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
"https://api2.heygen.com/v1/hyperframes/projects/publish",
expect.objectContaining({
method: "POST",
headers: { heygen_route: "canary" },
signal: expect.any(AbortSignal),
}),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
101 changes: 101 additions & 0 deletions packages/cli/src/utils/publishProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { basename, join, relative } from "node:path";
import { readdirSync, readFileSync, statSync } from "node:fs";
import AdmZip from "adm-zip";

const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]);
const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]);

export interface PublishArchiveResult {
buffer: Buffer;
fileCount: number;
}

export interface PublishedProjectResponse {
projectId: string;
title: string;
fileCount: number;
url: string;
claimToken: string;
}

function shouldIgnoreSegment(segment: string): boolean {
return segment.startsWith(".") || IGNORED_DIRS.has(segment) || IGNORED_FILES.has(segment);
}

function collectProjectFiles(rootDir: string, currentDir: string, paths: string[]): void {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
if (shouldIgnoreSegment(entry.name)) continue;
const absolutePath = join(currentDir, entry.name);
const relativePath = relative(rootDir, absolutePath).replaceAll("\\", "/");
if (!relativePath) continue;

if (entry.isDirectory()) {
collectProjectFiles(rootDir, absolutePath, paths);
continue;
}

if (!statSync(absolutePath).isFile()) continue;
paths.push(relativePath);
}
}

export function createPublishArchive(projectDir: string): PublishArchiveResult {
const filePaths: string[] = [];
collectProjectFiles(projectDir, projectDir, filePaths);
if (!filePaths.includes("index.html")) {
throw new Error("Project must include an index.html file at the root before publish.");
}

const archive = new AdmZip();
for (const filePath of filePaths) {
archive.addFile(filePath, readFileSync(join(projectDir, filePath)));
}

return {
buffer: archive.toBuffer(),
fileCount: filePaths.length,
};
}

export function getPublishApiBaseUrl(): string {
return (
process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] ||
process.env["HEYGEN_API_URL"] ||
"https://api2.heygen.com"
).replace(/\/$/, "");
}

export async function publishProjectArchive(projectDir: string): Promise<PublishedProjectResponse> {
const title = basename(projectDir);
const archive = createPublishArchive(projectDir);
const archiveBytes = new Uint8Array(archive.buffer.byteLength);
archiveBytes.set(archive.buffer);
const body = new FormData();
body.set("title", title);
body.set("file", new File([archiveBytes], `${title}.zip`, { type: "application/zip" }));
const headers: Record<string, string> = {
heygen_route: "canary",
};

const response = await fetch(`${getPublishApiBaseUrl()}/v1/hyperframes/projects/publish`, {
method: "POST",
body,
headers,
signal: AbortSignal.timeout(30_000),
});

const payload = await response.json().catch(() => null);
const message =
typeof payload?.message === "string" ? payload.message : "Failed to publish project";
if (!response.ok || !payload?.data) {
throw new Error(message);
}

return {
projectId: String(payload.data.project_id),
title: String(payload.data.title),
fileCount: Number(payload.data.file_count),
url: String(payload.data.url),
claimToken: String(payload.data.claim_token),
};
}
Loading