diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index a494aa782..e65317aa9 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7d6a83289..83528dbb0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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), diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 000000000..132cc8edb --- /dev/null +++ b/packages/cli/src/commands/publish.ts @@ -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; + } + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index fa77935fd..0f5630c72 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -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"], ], }, @@ -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"], diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts new file mode 100644 index 000000000..957be1a24 --- /dev/null +++ b/packages/cli/src/utils/publishProject.test.ts @@ -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"), "", "utf-8"); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets/logo.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"), "", "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 }); + } + }); +}); diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts new file mode 100644 index 000000000..536b19d47 --- /dev/null +++ b/packages/cli/src/utils/publishProject.ts @@ -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 { + 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 = { + 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), + }; +}