From 66df6fa922f874a9e67268d5e8dbecba5ed013b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Apr 2026 02:52:12 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20hyperframes=20publish=20?= =?UTF-8?q?=E2=80=94=20share=20projects=20via=20a=20public=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-command share: `hyperframes publish` starts the preview server, opens a public HTTPS tunnel to it, and prints a URL the user can paste into Slack / Figma / iPhone Safari / anywhere. Token-gated, read-only by default, explicit consent before exposure, auto-rebuilds the studio if its assets are missing so users never see a broken tunnel. # What changed - packages/cli/src/commands/publish.ts — new command. Consent prompt, mint session token, start preview through a fetch wrapper that gates the studio server, open the tunnel, print the URL. Ctrl-C reaps both children cleanly. - packages/cli/src/utils/tunnel.ts — provider detection (cloudflared preferred, tuns.sh ssh fallback) and openTunnel that spawns the child and resolves with the first emitted public URL. - packages/cli/src/utils/publishSecurity.ts — shared-secret token gate, mutation gate (read-only by default), read-only UI injection, interactive consent prompt. - packages/cli/src/server/studioServer.ts — StudioAssetsMissingError thrown at construction if dist is missing (no lazy-500 at request time). Fixed the dev-fallback path: was three levels up at `hyperframes-oss/studio/dist`, now correctly two at `packages/studio/dist`. - packages/cli/scripts/build-copy.sh — replaces the old silent `cp -r ../studio/dist/*`. Runs under `set -euo pipefail`, asserts the source index.html exists before copying, asserts the destination index.html exists after. A zero-file copy can no longer ship a broken CLI bundle. - Root package.json — build now serializes studio first, then the rest in parallel. CLI dropped its nested `build:studio`; the two parallel `vite build` invocations were clobbering each other's packages/studio/dist. - packages/cli/src/cli.ts, src/help.ts — register publish in the CLI, add it to the Getting Started group and root examples. - docs/packages/cli.mdx — documents the command, flags, and the read-only security model. # Tunnel Auto-picks the first available of: 1. cloudflared — Cloudflare's free quick tunnel. Most reliable. `brew install cloudflared` (Linux/Windows binaries equivalent). No account, no keys, no config. 2. tuns.sh — zero-install ssh -R fallback. Works on every machine with OpenSSH. `--provider cloudflared | tuns | auto` forces one. # Security hardening The studio server was built for localhost and everything is unauthenticated. PUT/POST/DELETE/PATCH on /api/projects/:id/files/* reads and overwrites the project directory. Exposing that surface unguarded over a public URL is how you ship a "drop a keylogger in their index.html" bug. Four composed defences: 1. Token gate. On startup, mint a 32-byte base64url token via crypto.randomBytes. Public URL is `${tunnelUrl}/?t=${token}#project/${name}`. First hit validates in constant time (timingSafeEqual), sets an httpOnly; Secure; SameSite=Lax session cookie, and 302-redirects to a clean URL so the token never lands in browser history or Referer. Every subsequent request needs the cookie. Anything else returns 404 (deliberately, not 401/403 — do not advertise that a server is here). Cookie TTL: 12 h. Implemented as a fetch wrapper rather than a Hono middleware because the studio routes are registered before publish gets the app; middlewares only apply to routes registered after them. Wrapping fetch gates traffic at the HTTP boundary and sidesteps ordering entirely. 2. Mutation gate. When --allow-edit is not set, refuse writes/deletes on `/api/projects/:id/files/*`, `POST /api/projects/:id/duplicate-file`, and `POST /api/projects/:id/render`. Returns `403 { "error": "forbidden", "reason": "…re-run with --allow-edit…" }`. Authenticated reads still work. 3. Read-only UI enforcement. The mutation gate closes the server side, but the studio UI does not know about the mode. Without this layer, visitors would type into a Monaco editor whose saves silently 403. Every response carries `X-HF-Publish-Readonly: 1`. HTML responses from the SPA (NOT `/api/*`, so the composition bundle served inside the player iframe is untouched) get a small inline +`; + +function injectReadonlyMarkup(html: string): string { + if (html.includes('id="hf-publish-readonly-style"')) return html; + const headCloseIdx = html.search(/<\/head>/i); + if (headCloseIdx !== -1) { + return html.slice(0, headCloseIdx) + READONLY_MARKUP + html.slice(headCloseIdx); + } + const bodyOpenIdx = html.search(/]*>/i); + if (bodyOpenIdx !== -1) { + const after = html.indexOf(">", bodyOpenIdx) + 1; + return html.slice(0, after) + READONLY_MARKUP + html.slice(after); + } + // Fallback: prepend. The browser still parses this as leading HTML fine. + return READONLY_MARKUP + html; +} + +/** + * Interactive consent prompt. Skipped when `skip` is true (usually via a + * `--yes` flag) or in a non-TTY environment where we can't prompt safely. + * Returns whether the user said yes; the caller is expected to abort on + * `false`. + */ +export async function confirmPublishSecurity(params: { + skip: boolean; + allowEdit: boolean; +}): Promise { + if (params.skip) return true; + if (!process.stdin.isTTY) { + console.error(); + console.error(` ${c.error("Refusing to publish non-interactively without --yes")}`); + console.error(); + console.error( + ` ${c.dim("`hyperframes publish` exposes the studio server to the public internet.")}`, + ); + console.error( + ` ${c.dim("Pass --yes to acknowledge you understand the exposure and continue.")}`, + ); + console.error(); + return false; + } + + console.log(); + console.log(` ${c.bold("hyperframes publish exposes the studio server via a public URL.")}`); + console.log(); + console.log( + ` ${c.dim("The tunnel URL carries a random 32-byte token — treat it like a password:")}`, + ); + console.log( + ` ${c.dim("anyone who learns the full URL can hit the server for up to 12 hours.")}`, + ); + console.log(); + if (params.allowEdit) { + console.log( + ` ${c.warn("--allow-edit is set. Visitors can read, write, and delete files in this project.")}`, + ); + } else { + console.log( + ` ${c.dim("Read-only by default — mutating endpoints return 403 and the UI is locked.")}`, + ); + console.log( + ` ${c.dim("Re-run with --allow-edit to let trusted collaborators edit files remotely.")}`, + ); + } + console.log(); + + const answer = await clack.confirm({ message: "Continue?" }); + if (clack.isCancel(answer)) return false; + return answer === true; +} diff --git a/packages/cli/src/utils/tunnel.test.ts b/packages/cli/src/utils/tunnel.test.ts new file mode 100644 index 000000000..7ef80430c --- /dev/null +++ b/packages/cli/src/utils/tunnel.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { extractTunnelUrl, pickTunnelProvider } from "./tunnel.js"; + +describe("extractTunnelUrl", () => { + it("pulls the trycloudflare URL out of a cloudflared banner", () => { + const chunk = `2026-04-18T00:00:00Z INF | https://partial-question-antique-representative.trycloudflare.com |`; + expect(extractTunnelUrl("cloudflared", chunk)).toBe( + "https://partial-question-antique-representative.trycloudflare.com", + ); + }); + + it("returns null on cloudflared output that hasn't emitted a URL yet", () => { + expect( + extractTunnelUrl("cloudflared", "INF Requesting new quick Tunnel on trycloudflare.com..."), + ).toBeNull(); + }); + + it("pulls the tuns.sh URL out of an ssh banner", () => { + const chunk = `Welcome to tuns.sh!\nhttps://calm-shoe.tuns.sh\nTraffic will be forwarded...\n`; + expect(extractTunnelUrl("tuns", chunk)).toBe("https://calm-shoe.tuns.sh"); + }); + + it("does not confuse a cloudflared URL for a tuns URL (and vice versa)", () => { + const cloudflared = "https://abc.trycloudflare.com"; + const tuns = "https://abc.tuns.sh"; + expect(extractTunnelUrl("tuns", cloudflared)).toBeNull(); + expect(extractTunnelUrl("cloudflared", tuns)).toBeNull(); + }); +}); + +describe("pickTunnelProvider", () => { + it("forcing 'tuns' always returns 'tuns' (ssh is assumed universal)", () => { + expect(pickTunnelProvider("tuns")).toBe("tuns"); + }); + + it("returns a concrete provider for 'auto'", () => { + const provider = pickTunnelProvider("auto"); + // On a bare CI runner without cloudflared installed we fall back to tuns; + // a dev box with cloudflared picks it. Either is a valid outcome — the + // contract is "a provider is chosen". + expect(provider === "cloudflared" || provider === "tuns").toBe(true); + }); + + it("returns null when the user forces a provider that isn't installed", () => { + // cloudflared may or may not be installed; if it isn't we expect null. + // We use a process.env override to simulate absence rather than tampering + // with PATH — simpler: just assert that forcing 'cloudflared' returns + // either the provider (when installed) or null (when not). + const result = pickTunnelProvider("cloudflared"); + expect(result === "cloudflared" || result === null).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/tunnel.ts b/packages/cli/src/utils/tunnel.ts new file mode 100644 index 000000000..d657fd1e8 --- /dev/null +++ b/packages/cli/src/utils/tunnel.ts @@ -0,0 +1,155 @@ +/** + * Open a public HTTPS tunnel to a local URL. Used by `hyperframes publish` to + * turn a local preview into a shareable link without the user running any + * third-party service themselves. + * + * Providers, in preference order: + * 1. `cloudflared` — most reliable free quick-tunnel (Cloudflare's own). + * Requires the `cloudflared` binary. Brew-installable on macOS. + * 2. `tuns.sh` — zero-install fallback via `ssh -R`. Works anywhere + * with OpenSSH (which is everywhere). Slightly less rock-solid than + * cloudflared but always available. + * + * Detection picks whichever is present. The caller can force a specific + * provider; `"none"` is returned if no provider is usable so the caller can + * render a helpful install hint. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { execSync } from "node:child_process"; + +export type TunnelProvider = "cloudflared" | "tuns"; + +export interface TunnelHandle { + /** Public HTTPS URL the tunnel terminates at. */ + publicUrl: string; + /** Provider that opened the tunnel, for UX/debug display. */ + provider: TunnelProvider; + /** Stop the tunnel. Idempotent. */ + close: () => void; +} + +/** + * Choose a tunnel provider for this environment. `preference` lets the user + * force one; otherwise we probe for `cloudflared` and fall back to `tuns`. + * Returns `null` only when the user explicitly requested an unavailable + * provider — `tuns` is assumed to be reachable because OpenSSH is always + * installed. + */ +export function pickTunnelProvider(preference?: TunnelProvider | "auto"): TunnelProvider | null { + if (preference === "cloudflared") return hasBinary("cloudflared") ? "cloudflared" : null; + if (preference === "tuns") return "tuns"; + if (hasBinary("cloudflared")) return "cloudflared"; + return "tuns"; +} + +function hasBinary(name: string): boolean { + try { + execSync(`command -v ${name}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Extract the first tunnel URL from a provider's output stream. Each provider + * prints it on a dedicated line within the first few seconds; we just watch + * for the known pattern. Exported so tests can exercise it in isolation. + */ +export function extractTunnelUrl(provider: TunnelProvider, chunk: string): string | null { + const pattern = + provider === "cloudflared" + ? /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i + : /https:\/\/[a-z0-9-]+\.tuns\.sh/i; + const match = chunk.match(pattern); + return match ? match[0] : null; +} + +/** + * Open a tunnel to `localUrl` and resolve with the public URL once the + * provider prints it. Rejects if the provider exits before emitting a URL + * or if `timeoutMs` elapses. + */ +export function openTunnel(params: { + provider: TunnelProvider; + localUrl: string; + timeoutMs?: number; +}): Promise { + const { provider, localUrl } = params; + const timeoutMs = params.timeoutMs ?? 30_000; + + const child = spawnProvider(provider, localUrl); + + return new Promise((resolve, reject) => { + let resolved = false; + + const timer = setTimeout(() => { + if (resolved) return; + resolved = true; + child.kill(); + reject(new Error(`Tunnel provider "${provider}" did not emit a URL within ${timeoutMs}ms`)); + }, timeoutMs); + + const onData = (data: Buffer): void => { + if (resolved) return; + const url = extractTunnelUrl(provider, data.toString()); + if (!url) return; + resolved = true; + clearTimeout(timer); + resolve({ + publicUrl: url, + provider, + close: () => { + try { + child.kill(); + } catch { + /* already dead */ + } + }, + }); + }; + + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + reject( + new Error(`Tunnel provider "${provider}" exited with code ${code} before emitting a URL`), + ); + }); + }); +} + +function spawnProvider(provider: TunnelProvider, localUrl: string): ChildProcess { + if (provider === "cloudflared") { + return spawn("cloudflared", ["tunnel", "--url", localUrl], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } + // tuns.sh: SSH remote-forward 80 → localhost:. Quiet flags keep the + // terminal noise down; BatchMode=yes forbids password prompts (we should + // never see one with `nokey@` but it's a belt-and-braces guard). + const port = new URL(localUrl).port || "80"; + return spawn( + "ssh", + [ + "-o", + "StrictHostKeyChecking=no", + "-o", + "BatchMode=yes", + "-o", + "ServerAliveInterval=60", + "-o", + "ExitOnForwardFailure=yes", + "-R", + `80:localhost:${port}`, + "nokey@tuns.sh", + ], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); +} From 6d6d9a041acae26dbb8d179064e449bd532ad5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 01:01:01 -0400 Subject: [PATCH 2/3] Wire CLI publish to persisted project URLs --- packages/cli/src/commands/publish.ts | 248 +++--------------- packages/cli/src/utils/publishProject.test.ts | 80 ++++++ packages/cli/src/utils/publishProject.ts | 97 +++++++ 3 files changed, 211 insertions(+), 214 deletions(-) create mode 100644 packages/cli/src/utils/publishProject.test.ts create mode 100644 packages/cli/src/utils/publishProject.ts diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 3b9282c41..132cc8edb 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,14 +1,3 @@ -/** - * `hyperframes publish` — start the preview and expose it through a public - * HTTPS tunnel so the project can be shared with a single URL. No account, - * no third-party tooling setup on the user's part — we pick the best tunnel - * available (`cloudflared` if present, else `ssh`-based tuns.sh as a - * universal fallback) and print the URL. - * - * The shared URL lives for the duration of the process. When the user hits - * Ctrl-C, both the preview server and the tunnel shut down. - */ - import { basename, resolve } from "node:path"; import { existsSync } from "node:fs"; import { join } from "node:path"; @@ -17,43 +6,27 @@ import * as clack from "@clack/prompts"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; -import { findPortAndServe, type FindPortResult } from "../server/portUtils.js"; import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; -import { openTunnel, pickTunnelProvider, type TunnelProvider } from "../utils/tunnel.js"; -import { confirmPublishSecurity, generateToken, secureFetch } from "../utils/publishSecurity.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"], - ["Force tuns.sh (skip cloudflared)", "hyperframes publish --provider tuns"], - ["Pick the port the preview binds to", "hyperframes publish --port 4830"], - ["Allow remote edits (trusted collaborator)", "hyperframes publish --allow-edit"], ["Skip the consent prompt (scripts)", "hyperframes publish --yes"], ]; export default defineCommand({ meta: { name: "publish", - description: "Start the preview and expose it via a public URL", + description: "Upload the project and return a stable public URL", }, args: { dir: { type: "positional", description: "Project directory", required: false }, - port: { type: "string", description: "Port to run the preview server on", default: "3002" }, - provider: { - type: "string", - description: "Tunnel provider: cloudflared | tuns | auto (default)", - default: "auto", - }, - "allow-edit": { - type: "boolean", - description: "Allow remote visitors to write / delete project files (default: read-only)", - default: false, - }, yes: { type: "boolean", alias: "y", - description: "Skip the security consent prompt", + description: "Skip the publish confirmation prompt", default: false, }, }, @@ -62,26 +35,7 @@ export default defineCommand({ const dir = resolve(rawArg ?? "."); const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir); - const startPort = parseInt(args.port ?? "3002", 10); - - const providerArg = (args.provider ?? "auto") as TunnelProvider | "auto"; - const provider = pickTunnelProvider(providerArg); - if (!provider) { - console.error(); - console.error(` ${c.error("Tunnel provider not available")}`); - console.error(); - console.error(` --provider ${providerArg} requires a binary that isn't installed.`); - console.error( - ` Install cloudflared → https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/`, - ); - console.error(` Or use the default → hyperframes publish (falls back to ssh + tuns.sh)`); - console.error(); - process.exitCode = 1; - return; - } - // Lint before publishing — users share what the preview will render, so - // surface any authoring issues up front. const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { const lintResult = lintProject({ dir, name: projectName, indexPath }); @@ -92,185 +46,51 @@ export default defineCommand({ } } - // Explicit consent before any public surface is exposed. The prompt - // spells out the exposure — first-time users shouldn't learn what - // `publish` does from a Twitter screenshot of someone else's laptop. - const allowEdit = args["allow-edit"] === true; - const approved = await confirmPublishSecurity({ skip: args.yes === true, allowEdit }); - if (!approved) { + if (args.yes !== true) { console.log(); - console.log(` ${c.dim("Aborted.")}`); + 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(); - return; - } - - clack.intro(c.bold("hyperframes publish")); - - // Mint the session token before the server so the middleware can - // reference it before any request lands. - const token = generateToken(); - - // 1. Start the preview server on an available port. The server's own - // fetch handler is wrapped with `secureFetch` — we don't use Hono - // middleware because the studio routes are registered inside - // `createStudioServer`, and `.use('*', ...)` added afterwards doesn't - // apply to already-registered routes. Wrapping `fetch` sidesteps that - // ordering issue entirely and enforces the policy at the HTTP boundary. - // - // `createStudioServer` throws `StudioAssetsMissingError` when the - // built studio UI is missing. We try to FIX rather than complain: - // if the CLI is running from inside the hyperframes-oss monorepo we - // can rebuild the studio ourselves. Only if that fails (or there's - // nothing to build — e.g. a broken global install) do we surface - // the error to the user. - const { createStudioServer, StudioAssetsMissingError } = - await import("../server/studioServer.js"); - - async function buildStudio(): Promise<{ ok: true } | { ok: false; reason: string }> { - return new Promise((resolveP) => { - try { - const { existsSync: exists } = require("node:fs") as typeof import("node:fs"); - const { spawn } = require("node:child_process") as typeof import("node:child_process"); - const { resolve: r } = require("node:path") as typeof import("node:path"); - // __dirname inside the built CLI is `dist/`. Walk up to the - // monorepo root and check for `packages/studio`. - const candidates = [ - r(__dirname, "..", "..", "studio"), - r(__dirname, "..", "..", "..", "studio"), - ]; - const studioPkg = candidates.find((p) => exists(r(p, "package.json"))); - if (!studioPkg) return resolveP({ ok: false, reason: "not a dev checkout" }); - - const bin = exists("/opt/homebrew/bin/bun") - ? "/opt/homebrew/bin/bun" - : exists("/usr/local/bin/bun") - ? "/usr/local/bin/bun" - : "bun"; - const child = spawn(bin, ["run", "build"], { - cwd: studioPkg, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("exit", (code: number | null) => { - resolveP( - code === 0 - ? { ok: true } - : { ok: false, reason: stderr.trim() || `exited with ${code ?? "?"}` }, - ); - }); - child.on("error", (err: Error) => resolveP({ ok: false, reason: err.message })); - } catch (err) { - resolveP({ ok: false, reason: (err as Error).message }); - } - }); - } - - async function makeStudio(): Promise> { - try { - return createStudioServer({ projectDir: dir, projectName }); - } catch (err: unknown) { - if (!(err instanceof StudioAssetsMissingError)) throw err; - // Try to auto-repair. - const repairSpinner = clack.spinner(); - repairSpinner.start("Studio assets missing — rebuilding"); - const result = await buildStudio(); - if (!result.ok) { - repairSpinner.stop(c.error("Could not rebuild studio")); - console.error(); - console.error(` ${c.error("Cannot publish — studio assets are missing.")}`); - console.error(); - console.error(` ${c.dim(`Auto-rebuild failed: ${result.reason}`)}`); - console.error(); - for (const line of err.message.split("\n").slice(1)) { - console.error(` ${c.dim(line)}`); - } - console.error(); - process.exit(1); - } - repairSpinner.stop(c.success("Studio rebuilt")); - return createStudioServer({ projectDir: dir, projectName }); + 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; } } - const { app } = await makeStudio(); - const guardedFetch = secureFetch(app.fetch.bind(app), { token, allowEdit }); - - const localSpinner = clack.spinner(); - localSpinner.start("Starting preview server..."); + clack.intro(c.bold("hyperframes publish")); + const publishSpinner = clack.spinner(); + publishSpinner.start("Uploading project..."); - let portResult: FindPortResult; try { - portResult = await findPortAndServe(guardedFetch, startPort, dir, /* forceNew */ true); - } catch (err: unknown) { - localSpinner.stop(c.error("Failed to start preview server")); - console.error(); - console.error(` ${(err as Error).message}`); - console.error(); - process.exitCode = 1; - return; - } - const localUrl = `http://localhost:${portResult.port}`; - localSpinner.stop(c.success(`Preview on ${localUrl}`)); - - // 2. Open the tunnel. - const tunnelSpinner = clack.spinner(); - tunnelSpinner.start(`Opening tunnel via ${provider}...`); + const published = await publishProjectArchive(dir); + const claimUrl = new URL(published.url); + claimUrl.searchParams.set("claim_token", published.claimToken); + publishSpinner.stop(c.success("Project published")); - let tunnelHandle; - try { - tunnelHandle = await openTunnel({ provider, localUrl }); + 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) { - tunnelSpinner.stop(c.error("Tunnel failed")); + publishSpinner.stop(c.error("Publish failed")); console.error(); console.error(` ${(err as Error).message}`); console.error(); - if (provider === "cloudflared") { - console.error(` Retry with → hyperframes publish --provider tuns`); - } else { - console.error(` tuns.sh may be unreachable. Install cloudflared and retry:`); - console.error(` brew install cloudflared`); - } - console.error(); process.exitCode = 1; return; } - - // Token goes in the query string of the entry URL only. The middleware - // immediately redirects to drop it and sets a session cookie; the token - // never appears in browser history or referrer headers after that. - const publicProjectUrl = `${tunnelHandle.publicUrl}/?t=${token}#project/${projectName}`; - tunnelSpinner.stop(c.success("Tunnel ready")); - - console.log(); - console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); - console.log(` ${c.dim("Local")} ${c.accent(localUrl)}`); - console.log(` ${c.dim("Public")} ${c.accent(publicProjectUrl)}`); - console.log(` ${c.dim("Provider")} ${tunnelHandle.provider}`); - console.log( - ` ${c.dim("Access")} ${allowEdit ? c.warn("read + write (--allow-edit)") : "read-only"}`, - ); - console.log(); - console.log(` ${c.dim("The Public URL carries a 32-byte token — treat it like a password.")}`); - console.log( - ` ${c.dim("Share it privately. Anyone with the full URL can reach the studio for up to 12 h.")}`, - ); - console.log(` ${c.dim("Press Ctrl+C to stop sharing.")}`); - console.log(); - - // Clean up the tunnel when the user Ctrl-Cs. The preview server is part - // of this process and goes away with it; only the tunnel child needs to - // be reaped explicitly so cloudflared/ssh don't hang around. - const cleanup = (): void => { - tunnelHandle.close(); - }; - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - process.on("exit", cleanup); - - // Block forever — preview and tunnel keep running until the user exits. - return new Promise(() => {}); }, }); diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts new file mode 100644 index 000000000..120668ef8 --- /dev/null +++ b/packages/cli/src/utils/publishProject.test.ts @@ -0,0 +1,80 @@ +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", + }, + }), + { 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); + } 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..8f7de1713 --- /dev/null +++ b/packages/cli/src/utils/publishProject.ts @@ -0,0 +1,97 @@ +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 response = await fetch(`${getPublishApiBaseUrl()}/v1/hyperframes/projects/publish`, { + method: "POST", + body, + 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), + }; +} From cf40ba987f9b9376b4b1adef5c56ed67ee36f6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 01:04:59 -0400 Subject: [PATCH 3/3] Remove tunnel-based HyperFrames publish flow --- docs/packages/cli.mdx | 16 +- package.json | 2 +- packages/cli/package.json | 6 +- packages/cli/scripts/build-copy.sh | 59 --- packages/cli/src/server/studioServer.test.ts | 83 +--- packages/cli/src/server/studioServer.ts | 97 +++-- packages/cli/src/utils/publishProject.test.ts | 1 + .../cli/src/utils/publishSecurity.test.ts | 227 ----------- packages/cli/src/utils/publishSecurity.ts | 377 ------------------ packages/cli/src/utils/tunnel.test.ts | 52 --- packages/cli/src/utils/tunnel.ts | 155 ------- 11 files changed, 64 insertions(+), 1011 deletions(-) delete mode 100755 packages/cli/scripts/build-copy.sh delete mode 100644 packages/cli/src/utils/publishSecurity.test.ts delete mode 100644 packages/cli/src/utils/publishSecurity.ts delete mode 100644 packages/cli/src/utils/tunnel.test.ts delete mode 100644 packages/cli/src/utils/tunnel.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 52fd669c4..0754ebea3 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -381,26 +381,22 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ ### `publish` - Expose the preview over a public HTTPS URL so collaborators can view the composition without having to install anything: + Upload the project and get back a stable `hyperframes.dev` URL: ```bash npx hyperframes publish [dir] - npx hyperframes publish --provider tuns + npx hyperframes publish --yes ``` | Flag | Description | |------|-------------| - | `--port` | Port to run the preview server on (default: 3002) | - | `--provider` | Tunnel provider: `cloudflared`, `tuns`, or `auto` (default) | - - `publish` starts the preview and opens a public HTTPS tunnel to it. The URL is printed and stays live for as long as the command runs — Ctrl+C tears everything down. + | `--yes` | Skip the confirmation prompt | - **Providers**, chosen automatically: + `publish` zips the current project, uploads it to the HyperFrames publish backend, and prints a stable `hyperframes.dev` URL for that stored project. - 1. **`cloudflared`** — Cloudflare's free quick tunnel. Requires the `cloudflared` binary (`brew install cloudflared`). Most reliable option. - 2. **`tuns.sh`** — zero-install fallback that uses your system's `ssh` to open a remote forward. Works anywhere OpenSSH is available (macOS, Linux, Windows 10+). + 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. - No accounts, no API keys, no configuration files. The tunnel URL rotates each run — share it immediately or re-run to get a new one. + 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` diff --git a/package.json b/package.json index 41cd848c0..6a7c897c0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/studio build && bun run --filter '*' build", + "build": "bun run --filter '!@hyperframes/cli' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/cli/package.json b/packages/cli/package.json index a03a58174..988e2326e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.4.3", + "version": "0.4.13-alpha.2", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", @@ -19,9 +19,8 @@ "dev": "tsx src/cli.ts", "build": "bun run build:fonts && tsup && bun run build:runtime && bun run build:copy", "build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts", - "//build:studio": "Intentionally removed — the root `bun run build` serializes studio first, so nesting another studio build here only creates a race on ../studio/dist.", "build:runtime": "tsx scripts/build-runtime.ts", - "build:copy": "bash -e scripts/build-copy.sh", + "build:copy": "node scripts/build-copy.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -44,6 +43,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/producer": "workspace:*", + "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", "@types/mime-types": "^3.0.1", "@types/node": "^22.0.0", diff --git a/packages/cli/scripts/build-copy.sh b/packages/cli/scripts/build-copy.sh deleted file mode 100755 index aef562267..000000000 --- a/packages/cli/scripts/build-copy.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# -# Copy the built studio UI, runtime skills, and static assets into -# `packages/cli/dist/` so `hyperframes preview` / `hyperframes publish` -# can serve them without reaching into sibling workspaces at runtime. -# -# This script fails loudly if any required source is missing — previously -# `cp -r ../studio/dist/*` would silently succeed with zero files when -# the studio was not yet built, producing a CLI bundle that shipped a -# tunnel URL whose every request returned `500 Studio not found`. That -# failure was invisible until someone tried to actually share a link. -# -# Preconditions: `bun run build:studio` has already run. We verify by -# checking for the sentinel `index.html`. - -set -euo pipefail - -CLI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$CLI_DIR" - -STUDIO_DIST="../studio/dist" -STUDIO_INDEX="$STUDIO_DIST/index.html" - -if [[ ! -f "$STUDIO_INDEX" ]]; then - echo "❌ build:copy: studio dist is missing ($STUDIO_INDEX)" >&2 - echo " The 'build:studio' step should have produced this file." >&2 - echo " From the repo root, run: bun install && bun run build" >&2 - exit 1 -fi - -mkdir -p dist/studio dist/docs dist/templates dist/skills dist/docker - -# Copy the studio bundle. Using a glob with shopt so an empty source -# directory would be caught as an error even if index.html existed. -shopt -s nullglob -STUDIO_FILES=("$STUDIO_DIST"/*) -if [[ ${#STUDIO_FILES[@]} -eq 0 ]]; then - echo "❌ build:copy: studio dist is empty" >&2 - exit 1 -fi -cp -r "${STUDIO_FILES[@]}" dist/studio/ - -# Verify the copy actually landed — a partial copy would otherwise only -# surface when someone hit the published URL. -if [[ ! -f "dist/studio/index.html" ]]; then - echo "❌ build:copy: dist/studio/index.html missing after copy" >&2 - exit 1 -fi - -cp -r src/templates/blank src/templates/_shared dist/templates/ -cp -r ../../skills/hyperframes ../../skills/hyperframes-cli ../../skills/gsap dist/skills/ -cp src/docker/Dockerfile.render dist/docker/ - -# Optional docs — silent if none exist. -if compgen -G "src/docs/*.md" > /dev/null; then - cp src/docs/*.md dist/docs/ -fi - -echo "✓ build:copy: dist/studio, templates, skills, docker copied" diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index e14127082..edad2f1e9 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -1,80 +1,9 @@ -/** - * Contract tests for the `createStudioServer` preflight. - * - * The core guarantee: if the studio UI assets are missing from disk, - * `createStudioServer` must throw `StudioAssetsMissingError` at the call - * site — not silently return a server whose every request 500s. That - * failure mode is what let a broken `hyperframes publish` tunnel ship a - * URL returning "Studio not found" to everyone the user sent it to. - */ +import { describe, expect, it } from "vitest"; +import { loadHyperframeRuntimeSource } from "@hyperframes/core"; +import { loadRuntimeSourceFallback } from "./runtimeSource.js"; -import { afterEach, describe, expect, it } from "vitest"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { StudioAssetsMissingError } from "./studioServer.js"; - -const tempProjects: string[] = []; - -function makeTempProject(): string { - const dir = join(tmpdir(), `hf-studio-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "index.html"), "composition"); - tempProjects.push(dir); - return dir; -} - -afterEach(() => { - while (tempProjects.length > 0) { - const dir = tempProjects.pop(); - if (dir) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - } -}); - -describe("StudioAssetsMissingError", () => { - it("is a named Error whose message lists the paths it searched", () => { - const err = new StudioAssetsMissingError(["/a/index.html", "/b/index.html"]); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("StudioAssetsMissingError"); - expect(err.message).toContain("/a/index.html"); - expect(err.message).toContain("/b/index.html"); - expect(err.message).toMatch(/bun run build/); - }); - - it("is catchable via `instanceof`", () => { - try { - throw new StudioAssetsMissingError(["/x"]); - } catch (err) { - expect(err instanceof StudioAssetsMissingError).toBe(true); - expect(err instanceof Error).toBe(true); - } - }); -}); - -describe("createStudioServer preflight", () => { - // We can't easily test the success path without a real built studio, - // but we CAN assert that the error surface exists and composes - // cleanly. The publish.ts caller does `err instanceof - // StudioAssetsMissingError` so the invariant we need is: the export - // is reachable, the class matches what `resolveDistDir` throws, and - // the error message is human-readable. - it("exports StudioAssetsMissingError for downstream catch blocks", async () => { - const mod = await import("./studioServer.js"); - expect(mod.StudioAssetsMissingError).toBeDefined(); - expect(typeof mod.StudioAssetsMissingError).toBe("function"); - }); - - it("accepts a valid project directory in its options", () => { - // Just exercises the options signature; no server is built because - // we don't want a real file-system watcher in a unit test. - const dir = makeTempProject(); - expect(dir).toMatch(/hf-studio-test/); +describe("loadRuntimeSourceFallback", () => { + it("loads runtime source from the published core entrypoint", async () => { + await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource()); }); }); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index cb23fda80..bb9a05d0d 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -10,6 +10,7 @@ import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; +import { loadRuntimeSourceFallback } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; import { createStudioApi, @@ -21,48 +22,12 @@ import { // ── Path resolution ───────────────────────────────────────────────────────── -/** - * Error thrown when the studio UI assets cannot be located. The message - * is actionable — it tells the user exactly where we looked and what to - * run to fix it. Callers (like `publish.ts`) can catch this and format a - * user-friendly error before any tunnel opens. - */ -export class StudioAssetsMissingError extends Error { - constructor(searchedPaths: string[]) { - super( - [ - "Studio UI assets not found.", - "", - "Searched:", - ...searchedPaths.map((p) => ` • ${p}`), - "", - "Fix: from the hyperframes-oss repo root, run:", - " bun install && bun run build", - "", - "If you installed via `npm i -g hyperframes`, re-install: your package is incomplete.", - ].join("\n"), - ); - this.name = "StudioAssetsMissingError"; - } -} - function resolveDistDir(): string { - // Shipped layout: CLI's build:copy step copies ../studio/dist into - // packages/cli/dist/studio, so this is the first place we check. const builtPath = resolve(__dirname, "studio"); if (existsSync(resolve(builtPath, "index.html"))) return builtPath; - - // Dev layout: running from the monorepo without build:copy, OR the - // auto-repair path in publish.ts just ran `bun run build` in the - // studio package. `__dirname` here is packages/cli/dist, so the - // sibling package is three levels up. - const devPath = resolve(__dirname, "..", "..", "studio", "dist"); + const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist"); if (existsSync(resolve(devPath, "index.html"))) return devPath; - - throw new StudioAssetsMissingError([ - resolve(builtPath, "index.html"), - resolve(devPath, "index.html"), - ]); + return builtPath; } function resolveRuntimePath(): string { @@ -264,7 +229,31 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, opts.seekTime); // Let the seek render settle. await new Promise((r) => setTimeout(r, 200)); - const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer; + let clip: { x: number; y: number; width: number; height: number } | undefined; + if (opts.selector) { + clip = await page.evaluate((selector: string) => { + const el = document.querySelector(selector); + if (!(el instanceof HTMLElement)) return undefined; + const rect = el.getBoundingClientRect(); + if (rect.width < 4 || rect.height < 4) return undefined; + const pad = 8; + const x = Math.max(0, rect.left - pad); + const y = Math.max(0, rect.top - pad); + const maxWidth = window.innerWidth - x; + const maxHeight = window.innerHeight - y; + return { + x, + y, + width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), + height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), + }; + }, opts.selector); + } + const screenshot = (await page.screenshot({ + type: "jpeg", + quality: 80, + ...(clip ? { clip } : {}), + })) as Buffer; return screenshot; } catch { return null; @@ -292,11 +281,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // CLI-specific routes (before shared API) app.get("/api/runtime.js", (c) => { - if (!existsSync(runtimePath)) return c.text("runtime not built", 404); - return c.body(readFileSync(runtimePath, "utf-8"), 200, { - "Content-Type": "text/javascript", - "Cache-Control": "no-store", - }); + const serve = async () => { + const runtimeSource = existsSync(runtimePath) + ? readFileSync(runtimePath, "utf-8") + : await loadRuntimeSourceFallback(); + if (!runtimeSource) return c.text("runtime not available", 404); + return c.body(runtimeSource, 200, { + "Content-Type": "text/javascript", + "Cache-Control": "no-store", + }); + }; + return serve(); }); app.get("/api/events", (c) => { @@ -347,12 +342,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }); }); - // SPA fallback. `studioDir` is guaranteed to contain index.html because - // resolveDistDir() throws StudioAssetsMissingError at construction time - // if the assets are missing — so this read cannot produce the old - // "Studio not found" 500 response. - const indexHtml = readFileSync(resolve(studioDir, "index.html"), "utf-8"); - app.get("*", (c) => c.html(indexHtml)); + // SPA fallback + app.get("*", (c) => { + const indexPath = resolve(studioDir, "index.html"); + if (!existsSync(indexPath)) { + return c.text("Studio not found. Rebuild with: pnpm run build", 500); + } + return c.html(readFileSync(indexPath, "utf-8")); + }); return { app, watcher }; } diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 120668ef8..a34cbaf3f 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -47,6 +47,7 @@ describe("publishProjectArchive", () => { title: "demo", file_count: 2, url: "https://hyperframes.dev/p/hfp_123", + claim_token: "claim-token", }, }), { status: 200 }, diff --git a/packages/cli/src/utils/publishSecurity.test.ts b/packages/cli/src/utils/publishSecurity.test.ts deleted file mode 100644 index 33e396fbe..000000000 --- a/packages/cli/src/utils/publishSecurity.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Hono } from "hono"; - -import { generateToken, secureFetch } from "./publishSecurity.js"; - -function makeGuarded(token: string, allowEdit: boolean) { - // Representative routes so the guard has realistic surface to defend. - const app = new Hono(); - app.get("/api/projects/:id/files/*", (c) => c.json({ ok: true, kind: "read" })); - app.put("/api/projects/:id/files/*", (c) => c.json({ ok: true, kind: "write" })); - app.post("/api/projects/:id/files/*", (c) => c.json({ ok: true, kind: "create" })); - app.delete("/api/projects/:id/files/*", (c) => c.json({ ok: true, kind: "delete" })); - app.patch("/api/projects/:id/files/*", (c) => c.json({ ok: true, kind: "rename" })); - app.post("/api/projects/:id/duplicate-file", (c) => c.json({ ok: true, kind: "dup" })); - app.post("/api/projects/:id/render", (c) => c.json({ ok: true, kind: "render" })); - app.get("/", (c) => - c.html("studioroot"), - ); - // The bundled composition HTML served inside the player iframe. Must - // NOT get the banner injected — that caused a double-banner bug and - // pushed the composition layout around. - app.get("/api/projects/:id/bundle", (c) => - c.html("compcomp"), - ); - app.get("/api/meta", (c) => c.json({ version: 1 })); - - const guarded = secureFetch(app.fetch.bind(app), { token, allowEdit }); - const hit = (path: string, init?: RequestInit): Promise => - guarded(new Request("http://localhost" + path, init)); - return { hit }; -} - -describe("generateToken", () => { - it("produces distinct 32-byte base64url strings", () => { - const a = generateToken(); - const b = generateToken(); - expect(a).not.toBe(b); - expect(a.length).toBe(43); - expect(a).toMatch(/^[A-Za-z0-9_-]+$/); - }); -}); - -describe("secureFetch — token gate", () => { - it("404s a request with neither cookie nor matching query token", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/"); - expect(res.status).toBe(404); - }); - - it("404s a request with a wrong query token", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/?t=wrong"); - expect(res.status).toBe(404); - }); - - it("redirects the first-visit request and sets a session cookie", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/?t=secret"); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - const setCookie = res.headers.get("set-cookie") ?? ""; - expect(setCookie).toMatch(/^hf_publish_session=secret/); - expect(setCookie).toMatch(/HttpOnly/i); - expect(setCookie).toMatch(/Secure/i); - expect(setCookie).toMatch(/SameSite=Lax/i); - }); - - it("strips the token from the redirect target — it never lands in browser history", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/?t=secret&foo=bar"); - expect(res.status).toBe(302); - const location = res.headers.get("location") ?? ""; - expect(location).not.toContain("t=secret"); - expect(location).toContain("foo=bar"); - }); - - it("lets through an API request carrying the cookie", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/meta", { headers: { cookie: "hf_publish_session=secret" } }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.version).toBe(1); - }); - - it("404s a request whose cookie has the wrong value", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/", { headers: { cookie: "hf_publish_session=wrong" } }); - expect(res.status).toBe(404); - }); - - it("is constant-time-safe against length-prefix attacks", async () => { - const { hit } = makeGuarded("a".repeat(43), false); - const short = await hit("/?t=aaa"); - const long = await hit("/?t=" + "a".repeat(80)); - expect(short.status).toBe(404); - expect(long.status).toBe(404); - }); -}); - -describe("secureFetch — mutation gate (read-only)", () => { - const authed = { cookie: "hf_publish_session=secret" } as Record; - - it("allows GET on files — reading is fine in read-only mode", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/files/index.html", { headers: authed }); - expect(res.status).toBe(200); - }); - - it("blocks PUT on files", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/files/index.html", { method: "PUT", headers: authed }); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.error).toBe("forbidden"); - }); - - it("blocks POST on files (create)", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/files/new.html", { method: "POST", headers: authed }); - expect(res.status).toBe(403); - }); - - it("blocks DELETE on files", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/files/index.html", { - method: "DELETE", - headers: authed, - }); - expect(res.status).toBe(403); - }); - - it("blocks PATCH on files (rename)", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/files/index.html", { - method: "PATCH", - headers: authed, - }); - expect(res.status).toBe(403); - }); - - it("blocks POST /duplicate-file", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/duplicate-file", { method: "POST", headers: authed }); - expect(res.status).toBe(403); - }); - - it("blocks POST /render", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/render", { method: "POST", headers: authed }); - expect(res.status).toBe(403); - }); -}); - -describe("secureFetch — mutation gate (--allow-edit)", () => { - const authed = { cookie: "hf_publish_session=secret" } as Record; - - it("allows PUT on files when allowEdit is true", async () => { - const { hit } = makeGuarded("secret", true); - const res = await hit("/api/projects/foo/files/index.html", { method: "PUT", headers: authed }); - expect(res.status).toBe(200); - }); - - it("still requires the token even with allowEdit", async () => { - const { hit } = makeGuarded("secret", true); - const res = await hit("/api/projects/foo/files/index.html", { method: "PUT" }); - expect(res.status).toBe(404); - }); -}); - -describe("secureFetch — read-only UI enforcement", () => { - const authed = { cookie: "hf_publish_session=secret" } as Record; - - it("sets X-HF-Publish-Readonly on every response in read-only mode", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/meta", { headers: authed }); - expect(res.headers.get("x-hf-publish-readonly")).toBe("1"); - }); - - it("does NOT set the readonly header when --allow-edit is on", async () => { - const { hit } = makeGuarded("secret", true); - const res = await hit("/api/meta", { headers: authed }); - expect(res.headers.get("x-hf-publish-readonly")).toBeNull(); - }); - - it("injects the read-only banner + script into HTML responses in read-only mode", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/", { headers: authed }); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toContain("hf-publish-readonly-style"); - expect(body).toContain("hf-publish-readonly-banner"); - expect(body).toContain("__HF_PUBLISH_READONLY__"); - // Body must still be well-formed HTML with our content. - expect(body).toMatch(/<\/head>/); - expect(body).toMatch(/root/); - }); - - it("does NOT inject anything when --allow-edit is on", async () => { - const { hit } = makeGuarded("secret", true); - const res = await hit("/", { headers: authed }); - const body = await res.text(); - expect(body).not.toContain("hf-publish-readonly-style"); - expect(body).not.toContain("__HF_PUBLISH_READONLY__"); - }); - - it("does NOT rewrite non-HTML responses", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/meta", { headers: authed }); - expect(res.headers.get("content-type")).toMatch(/application\/json/); - const body = await res.text(); - expect(body).not.toContain("hf-publish-readonly-style"); - expect(JSON.parse(body).version).toBe(1); - }); - - it("does NOT inject into HTML served under /api/* (composition bundle in the player iframe)", async () => { - const { hit } = makeGuarded("secret", false); - const res = await hit("/api/projects/foo/bundle", { headers: authed }); - expect(res.status).toBe(200); - // Header still ships so clients can detect the mode. - expect(res.headers.get("x-hf-publish-readonly")).toBe("1"); - // But the body must be untouched — no banner inside the iframe. - const body = await res.text(); - expect(body).not.toContain("hf-publish-readonly-style"); - expect(body).not.toContain("__HF_PUBLISH_READONLY__"); - expect(body).toContain("comp"); - }); -}); diff --git a/packages/cli/src/utils/publishSecurity.ts b/packages/cli/src/utils/publishSecurity.ts deleted file mode 100644 index 0d84c2bfa..000000000 --- a/packages/cli/src/utils/publishSecurity.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Security hardening for `hyperframes publish`. - * - * The studio server was built for localhost-only development — every - * endpoint is unauthenticated and `PUT` / `POST` / `DELETE` / `PATCH` on - * `/api/projects/:id/files/*` lets the caller read and overwrite the - * project directory. Exposing that surface over a public URL without - * guardrails is how you ship a "drop a keylogger in their `index.html`" - * bug. This module provides four defences, designed to compose: - * - * 1. Token gate — shared-secret cookie gate. An inbound request must - * present either `?t=` on its first hit (which also sets an - * httpOnly cookie for the session) OR the cookie on every subsequent - * hit. Requests without either get 404 — we deliberately don't 403 - * so the URL leaks no information about a server existing. - * - * 2. Mutation gate — refuses `PUT` / `POST` / `DELETE` / `PATCH` on - * routes that modify the project filesystem when `allowEdit` is - * false. Read-only viewers get full preview; trusted collaborators - * can opt in via `publish --allow-edit`. - * - * 3. Read-only UI enforcement — when `allowEdit` is false, every HTML - * response is rewritten to inject a banner + CSS that blocks the - * Monaco editor from accepting keystrokes. Every response also - * carries `X-HF-Publish-Readonly: 1` so server-side callers (and - * future studio code) can detect the mode. The existing mutation - * gate is the real security boundary — this purely fixes the UX - * gap where the studio UI happily let visitors type into an editor - * whose saves the server would silently 403. - * - * 4. `confirmPublishSecurity()` — explicit consent on startup. Prints - * the exposure surface and waits for the user to hit Enter (skip - * with `--yes`). First-time users see the risk before the tunnel - * goes up. - */ - -import { randomBytes, timingSafeEqual } from "node:crypto"; -import * as clack from "@clack/prompts"; - -import { c } from "../ui/colors.js"; - -type FetchHandler = (request: Request) => Promise | Response; - -export interface SecureFetchOptions { - token: string; - allowEdit: boolean; -} - -/** Name of the cookie that carries the authenticated-session marker. */ -const SESSION_COOKIE = "hf_publish_session"; -/** Query parameter the first-hit URL includes to exchange for the cookie. */ -const TOKEN_QUERY_PARAM = "t"; -/** Session lifetime. Long enough for a working session, short enough to limit leaked-cookie damage. */ -const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; -/** Response header emitted on every read-only response. Lets the studio UI (or any client) detect the mode. */ -const READONLY_RESPONSE_HEADER = "X-HF-Publish-Readonly"; - -/** Mint a 32-byte base64url token, unguessable for tunnel URLs. */ -export function generateToken(): string { - return randomBytes(32).toString("base64url"); -} - -/** - * Constant-time comparison. Don't leak which character mismatched via - * short-circuit timing. - */ -function safeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - try { - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); - } catch { - return false; - } -} - -/** - * Wrap an existing fetch handler with the token gate, mutation gate, and - * read-only UI enforcement. The wrapped handler enforces the policy - * independently of the underlying framework's middleware semantics — - * specifically, Hono `.use()` only applies to routes registered *after* - * it, and by the time `publish.ts` wants to gate the studio server the - * routes have already been wired. A fetch wrapper sidesteps that - * ordering entirely. - * - * Policy: - * 1. Token gate — first-hit with `?t=` exchanges the param for - * an httpOnly session cookie and redirects to the clean URL (so the - * token never lands in browser history or Referer headers). Every - * subsequent request must carry the cookie. Anything else → 404 - * (not 401/403: don't advertise that something is here). - * 2. Mutation gate — when `allowEdit` is false, refuse writes/deletes - * on `/api/projects/:id/files/*`, `POST /duplicate-file`, and - * `POST /render`. Authenticated reads still work. - * 3. Read-only UI — when `allowEdit` is false, HTML responses get a - * ``; - -function injectReadonlyMarkup(html: string): string { - if (html.includes('id="hf-publish-readonly-style"')) return html; - const headCloseIdx = html.search(/<\/head>/i); - if (headCloseIdx !== -1) { - return html.slice(0, headCloseIdx) + READONLY_MARKUP + html.slice(headCloseIdx); - } - const bodyOpenIdx = html.search(/]*>/i); - if (bodyOpenIdx !== -1) { - const after = html.indexOf(">", bodyOpenIdx) + 1; - return html.slice(0, after) + READONLY_MARKUP + html.slice(after); - } - // Fallback: prepend. The browser still parses this as leading HTML fine. - return READONLY_MARKUP + html; -} - -/** - * Interactive consent prompt. Skipped when `skip` is true (usually via a - * `--yes` flag) or in a non-TTY environment where we can't prompt safely. - * Returns whether the user said yes; the caller is expected to abort on - * `false`. - */ -export async function confirmPublishSecurity(params: { - skip: boolean; - allowEdit: boolean; -}): Promise { - if (params.skip) return true; - if (!process.stdin.isTTY) { - console.error(); - console.error(` ${c.error("Refusing to publish non-interactively without --yes")}`); - console.error(); - console.error( - ` ${c.dim("`hyperframes publish` exposes the studio server to the public internet.")}`, - ); - console.error( - ` ${c.dim("Pass --yes to acknowledge you understand the exposure and continue.")}`, - ); - console.error(); - return false; - } - - console.log(); - console.log(` ${c.bold("hyperframes publish exposes the studio server via a public URL.")}`); - console.log(); - console.log( - ` ${c.dim("The tunnel URL carries a random 32-byte token — treat it like a password:")}`, - ); - console.log( - ` ${c.dim("anyone who learns the full URL can hit the server for up to 12 hours.")}`, - ); - console.log(); - if (params.allowEdit) { - console.log( - ` ${c.warn("--allow-edit is set. Visitors can read, write, and delete files in this project.")}`, - ); - } else { - console.log( - ` ${c.dim("Read-only by default — mutating endpoints return 403 and the UI is locked.")}`, - ); - console.log( - ` ${c.dim("Re-run with --allow-edit to let trusted collaborators edit files remotely.")}`, - ); - } - console.log(); - - const answer = await clack.confirm({ message: "Continue?" }); - if (clack.isCancel(answer)) return false; - return answer === true; -} diff --git a/packages/cli/src/utils/tunnel.test.ts b/packages/cli/src/utils/tunnel.test.ts deleted file mode 100644 index 7ef80430c..000000000 --- a/packages/cli/src/utils/tunnel.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractTunnelUrl, pickTunnelProvider } from "./tunnel.js"; - -describe("extractTunnelUrl", () => { - it("pulls the trycloudflare URL out of a cloudflared banner", () => { - const chunk = `2026-04-18T00:00:00Z INF | https://partial-question-antique-representative.trycloudflare.com |`; - expect(extractTunnelUrl("cloudflared", chunk)).toBe( - "https://partial-question-antique-representative.trycloudflare.com", - ); - }); - - it("returns null on cloudflared output that hasn't emitted a URL yet", () => { - expect( - extractTunnelUrl("cloudflared", "INF Requesting new quick Tunnel on trycloudflare.com..."), - ).toBeNull(); - }); - - it("pulls the tuns.sh URL out of an ssh banner", () => { - const chunk = `Welcome to tuns.sh!\nhttps://calm-shoe.tuns.sh\nTraffic will be forwarded...\n`; - expect(extractTunnelUrl("tuns", chunk)).toBe("https://calm-shoe.tuns.sh"); - }); - - it("does not confuse a cloudflared URL for a tuns URL (and vice versa)", () => { - const cloudflared = "https://abc.trycloudflare.com"; - const tuns = "https://abc.tuns.sh"; - expect(extractTunnelUrl("tuns", cloudflared)).toBeNull(); - expect(extractTunnelUrl("cloudflared", tuns)).toBeNull(); - }); -}); - -describe("pickTunnelProvider", () => { - it("forcing 'tuns' always returns 'tuns' (ssh is assumed universal)", () => { - expect(pickTunnelProvider("tuns")).toBe("tuns"); - }); - - it("returns a concrete provider for 'auto'", () => { - const provider = pickTunnelProvider("auto"); - // On a bare CI runner without cloudflared installed we fall back to tuns; - // a dev box with cloudflared picks it. Either is a valid outcome — the - // contract is "a provider is chosen". - expect(provider === "cloudflared" || provider === "tuns").toBe(true); - }); - - it("returns null when the user forces a provider that isn't installed", () => { - // cloudflared may or may not be installed; if it isn't we expect null. - // We use a process.env override to simulate absence rather than tampering - // with PATH — simpler: just assert that forcing 'cloudflared' returns - // either the provider (when installed) or null (when not). - const result = pickTunnelProvider("cloudflared"); - expect(result === "cloudflared" || result === null).toBe(true); - }); -}); diff --git a/packages/cli/src/utils/tunnel.ts b/packages/cli/src/utils/tunnel.ts deleted file mode 100644 index d657fd1e8..000000000 --- a/packages/cli/src/utils/tunnel.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Open a public HTTPS tunnel to a local URL. Used by `hyperframes publish` to - * turn a local preview into a shareable link without the user running any - * third-party service themselves. - * - * Providers, in preference order: - * 1. `cloudflared` — most reliable free quick-tunnel (Cloudflare's own). - * Requires the `cloudflared` binary. Brew-installable on macOS. - * 2. `tuns.sh` — zero-install fallback via `ssh -R`. Works anywhere - * with OpenSSH (which is everywhere). Slightly less rock-solid than - * cloudflared but always available. - * - * Detection picks whichever is present. The caller can force a specific - * provider; `"none"` is returned if no provider is usable so the caller can - * render a helpful install hint. - */ - -import { spawn, type ChildProcess } from "node:child_process"; -import { execSync } from "node:child_process"; - -export type TunnelProvider = "cloudflared" | "tuns"; - -export interface TunnelHandle { - /** Public HTTPS URL the tunnel terminates at. */ - publicUrl: string; - /** Provider that opened the tunnel, for UX/debug display. */ - provider: TunnelProvider; - /** Stop the tunnel. Idempotent. */ - close: () => void; -} - -/** - * Choose a tunnel provider for this environment. `preference` lets the user - * force one; otherwise we probe for `cloudflared` and fall back to `tuns`. - * Returns `null` only when the user explicitly requested an unavailable - * provider — `tuns` is assumed to be reachable because OpenSSH is always - * installed. - */ -export function pickTunnelProvider(preference?: TunnelProvider | "auto"): TunnelProvider | null { - if (preference === "cloudflared") return hasBinary("cloudflared") ? "cloudflared" : null; - if (preference === "tuns") return "tuns"; - if (hasBinary("cloudflared")) return "cloudflared"; - return "tuns"; -} - -function hasBinary(name: string): boolean { - try { - execSync(`command -v ${name}`, { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -/** - * Extract the first tunnel URL from a provider's output stream. Each provider - * prints it on a dedicated line within the first few seconds; we just watch - * for the known pattern. Exported so tests can exercise it in isolation. - */ -export function extractTunnelUrl(provider: TunnelProvider, chunk: string): string | null { - const pattern = - provider === "cloudflared" - ? /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i - : /https:\/\/[a-z0-9-]+\.tuns\.sh/i; - const match = chunk.match(pattern); - return match ? match[0] : null; -} - -/** - * Open a tunnel to `localUrl` and resolve with the public URL once the - * provider prints it. Rejects if the provider exits before emitting a URL - * or if `timeoutMs` elapses. - */ -export function openTunnel(params: { - provider: TunnelProvider; - localUrl: string; - timeoutMs?: number; -}): Promise { - const { provider, localUrl } = params; - const timeoutMs = params.timeoutMs ?? 30_000; - - const child = spawnProvider(provider, localUrl); - - return new Promise((resolve, reject) => { - let resolved = false; - - const timer = setTimeout(() => { - if (resolved) return; - resolved = true; - child.kill(); - reject(new Error(`Tunnel provider "${provider}" did not emit a URL within ${timeoutMs}ms`)); - }, timeoutMs); - - const onData = (data: Buffer): void => { - if (resolved) return; - const url = extractTunnelUrl(provider, data.toString()); - if (!url) return; - resolved = true; - clearTimeout(timer); - resolve({ - publicUrl: url, - provider, - close: () => { - try { - child.kill(); - } catch { - /* already dead */ - } - }, - }); - }; - - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - - child.on("exit", (code) => { - if (resolved) return; - resolved = true; - clearTimeout(timer); - reject( - new Error(`Tunnel provider "${provider}" exited with code ${code} before emitting a URL`), - ); - }); - }); -} - -function spawnProvider(provider: TunnelProvider, localUrl: string): ChildProcess { - if (provider === "cloudflared") { - return spawn("cloudflared", ["tunnel", "--url", localUrl], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - } - // tuns.sh: SSH remote-forward 80 → localhost:. Quiet flags keep the - // terminal noise down; BatchMode=yes forbids password prompts (we should - // never see one with `nokey@` but it's a belt-and-braces guard). - const port = new URL(localUrl).port || "80"; - return spawn( - "ssh", - [ - "-o", - "StrictHostKeyChecking=no", - "-o", - "BatchMode=yes", - "-o", - "ServerAliveInterval=60", - "-o", - "ExitOnForwardFailure=yes", - "-R", - `80:localhost:${port}`, - "nokey@tuns.sh", - ], - { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, - ); -}