feat(cli): hyperframes publish — share projects via a public URL

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.

- 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.

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.

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 <style> + <script>
   injected before `</head>`. The style neutralises Monaco /
   CodeMirror surfaces via pointer-events: none,
   caret-color: transparent, user-select: none. The script sets
   window.__HF_PUBLISH_READONLY__ = true, renders a compact pill
   centered at the top of the viewport ("READ-ONLY · Published
   tunnel"), and monkey-patches window.fetch to short-circuit
   mutating calls client-side so the UI does not loop through
   doomed 403s.
4. Explicit consent on startup. Before the server binds, a clack
   confirm prompt spells out the exposure: token-is-a-password
   guidance, the 12 h session lifetime, and — when --allow-edit is
   set — a warning that visitors can read/write/delete project
   files. --yes / -y skips for scripts. In non-TTY environments
   without --yes, publish refuses to proceed rather than silently
   exposing.

Previously, running publish against a dev checkout without a built
studio produced "Studio not found. Rebuild with: pnpm run build" on
every request — after the tunnel was open and the URL shared. We
fix it instead: createStudioServer throws
StudioAssetsMissingError at construction, publish catches it,
spawns `bun run build` in packages/studio with a spinner, retries
createStudioServer once the build succeeds. The error surface only
fires when auto-rebuild itself fails (broken global install, etc.),
and lists every path searched plus the `bun install && bun run
build` guidance. End-to-end: deleting both packages/studio/dist
and packages/cli/dist/studio and running publish now rebuilds the
studio and opens the tunnel without user intervention.

- --port <n>               Preview port (default 3002).
- --provider <p>           cloudflared | tuns | auto.
- --allow-edit             Remote visitors can write/delete files.
                           Off by default.
- --yes / -y               Skip the consent prompt.

- No info leak on unauthenticated probes — random visitors see 404
  whether a path exists or not.
- Token never leaves the first URL — redirect strips it before any
  HTML is served, so no iframe Referer can leak it.
- Constant-time comparison for both token and cookie checks.
- No persisted state — token lives for the lifetime of the command,
  Ctrl-C nukes it.
- Read-only cannot be bypassed by a malicious client; the
  server-side 403 is the real stop.
- zero-file build:copy can no longer ship a broken CLI.

- src/utils/publishSecurity.test.ts — 23 cases: token exchange,
  cookie flow, mutation-gate allow/deny matrix,
  X-HF-Publish-Readonly header, HTML-injection + no-op on
  --allow-edit / non-HTML / /api/* paths.
- src/utils/tunnel.test.ts — 7 cases for the URL extractor
  (per-provider banner parsing, cross-provider contamination
  guard) and provider picker semantics.
- src/server/studioServer.test.ts — StudioAssetsMissingError shape
  and export for downstream catch blocks.
- Full CLI suite: 144/144 pass.
- tsc --noEmit clean, tsup build clean.
This commit is contained in:
Miguel Ángel
2026-04-22 10:05:11 -04:00
parent 29b6274ebc
commit 339e49dfec
6 changed files with 301 additions and 50 deletions
+22 -39
View File
@@ -305,12 +305,6 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
# Adjust speech speed
npx hyperframes tts "Slow and clear" --speed 0.8
# Generate Spanish speech (lang auto-detected from the `e` voice prefix)
npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
# Override the phonemizer (read English text with a French voice)
npx hyperframes tts "Bonjour le monde" --voice af_heart --lang fr-fr
# Read text from a file
npx hyperframes tts script.txt
@@ -323,14 +317,9 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--output, -o` | Output file path (default: `speech.wav` in current directory) |
| `--voice, -v` | Voice ID (run `--list` to see options) |
| `--speed, -s` | Speech speed multiplier (default: 1.0) |
| `--lang, -l` | Phonemizer locale (`en-us`, `en-gb`, `es`, `fr-fr`, `hi`, `it`, `pt-br`, `ja`, `zh`). When omitted, inferred from the voice ID prefix. |
| `--list` | List available voices and exit |
| `--json` | Output result as JSON |
<Tip>
Voice IDs encode the phonemizer language in their first letter (`a`=American, `b`=British, `e`=Spanish, `f`=French, `h`=Hindi, `i`=Italian, `j`=Japanese, `p`=Brazilian Portuguese, `z`=Mandarin). `--lang` is only needed when you want to override that — for example, giving English text a French phonemizer for a stylized accent.
</Tip>
<Tip>
Combine `tts` with `transcribe` to generate narration and word-level timestamps for captions in a single workflow: generate the audio with `tts`, then transcribe the output with `transcribe` to get word-level timing.
</Tip>
@@ -384,16 +373,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
Opens your composition in the Hyperframes Studio with live preview. Edits to `index.html` and any referenced sub-compositions are reflected automatically. The preview uses the same Hyperframes runtime as production rendering, so what you see is what you get.
<Note>
Visual output matches render exactly. Playback *performance* does not: preview plays in real time in your browser, so paint-heavy compositions (large images, stacked `backdrop-filter` layers, many shadowed elements) may stutter depending on your hardware. The rendered mp4 is always accurate regardless — render captures frames one at a time, so per-frame cost shows up as longer render duration, not dropped frames. See [Performance](/guides/performance) for details.
</Note>
The preview server runs in three modes, auto-detected:
1. **Embedded mode** (default for `npx`) — runs a standalone server with the studio bundled in the CLI. Zero extra dependencies.
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:
@@ -476,15 +480,14 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--format` | mp4, webm, mov | mp4 | Output format (WebM/MOV render with transparency) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--hdr` | — | off | Detect HDR sources and output HDR10 (H.265 10-bit, BT.2020 PQ/HLG). MP4 only. SDR-only compositions are unaffected. See [HDR Rendering](/guides/hdr) |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--crf` | 0–51 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
| `--workers` | 1-8 | 4 | Parallel render workers |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` | — | off | Suppress verbose output |
CRF and target bitrate are now driven by `--quality`. For programmatic renders, `RenderConfig.crf` and `RenderConfig.videoBitrate` still accept overrides.
#### WebM with Transparency
Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video.
@@ -646,26 +649,6 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--cursor` | Install to Cursor (`.cursor/skills/` in current project) |
Skills are fetched from GitHub and include composition authoring, GSAP animation patterns, registry block/component wiring, and other domain-specific knowledge. The `init` command also offers to install skills automatically after scaffolding a project.
#### Troubleshooting: `fatal: active post-checkout hook found during git clone`
If you installed Git LFS globally (`git lfs install`), Git 2.45+ refuses to run the LFS post-checkout hook during any `git clone` — including the clone the upstream `skills` CLI performs under the hood. The error looks like:
```
■ Failed to clone repository
fatal: active `post-checkout` hook found during `git clone`
└ Installation failed
```
**Using `hyperframes skills` is already fine** — as of v0.4.5 the CLI sets `GIT_CLONE_PROTECTION_ACTIVE=0` on the child environment, which is the opt-in knob Git provides for exactly this case. You don't need to do anything.
**If you ran `npx skills add heygen-com/hyperframes` directly** (bypassing the HyperFrames CLI), set the env var yourself:
```bash
GIT_CLONE_PROTECTION_ACTIVE=0 npx skills add heygen-com/hyperframes
```
This is tracked in [GH #316](https://github.com/heygen-com/hyperframes/issues/316). An upstream fix in the `skills` CLI itself is the right long-term answer; until that lands, the env var is the correct workaround.
</Tab>
</Tabs>