Commit Graph
474 Commits
Author SHA1 Message Date
Miguel Ángel bb9dbfdebd chore: bump version to 0.6.75 2026-06-05 22:17:42 -04:00
Vance Ingalls 1f37920fe1 fix(cli): re-validate SSRF denylist on redirects + harden isPrivateUrl (#1212)
## Summary

- Adds `safeFetch`, a redirect-aware wrapper around `fetch` that re-runs the SSRF denylist on every hop before following a redirect.
- Routes `fetchBuffer` and the Lottie media fetch through `safeFetch` so redirect chains can't bounce through a public URL to reach an internal or cloud-metadata host.
- Hardens `isPrivateUrl` to also block `0.0.0.0` / `0.0.0.0/8`, IPv6 loopback (`::1`), IPv4-mapped (`::ffff:…`), unique-local (`fc00::/7`), and link-local (`fe80::/10`) ranges.

## Security

**F-002 MED** — `fetchBuffer` followed redirects without re-checking the denylist on the destination. A `30x` redirect from an allowlisted public URL to `169.254.169.254` or an internal host would succeed, leaking the response to the caller (e.g. captured page assets written to local disk).

**F-003 MED** — `isPrivateUrl` did not cover `0.0.0.0` (maps to localhost on most OSes), IPv6 loopback, or IPv6 private ranges. An asset URL using those addresses would bypass the denylist. Alternate IPv4 encodings (decimal/octal/hex) are already normalized to dotted-quad by WHATWG URL parsing and remain blocked.

## Test plan

- [x] Unit tests cover redirect-chain blocking (redirect to metadata IP rejected)
- [x] Unit tests cover new `isPrivateUrl` address forms (`0.0.0.0`, `::1`, `fc00::1`, `fe80::1`, `::ffff:192.168.1.1`)
- [x] Existing fetch and asset-download tests pass
2026-06-05 17:01:00 -07:00
James Russo bacfb17538 feat(producer): auto low-memory safe render profile (#1225)
## What

Adds an auto-detected **low-memory safe render profile**. On hosts at or below 8 GB total RAM, the render pipeline collapses to its cheapest shape instead of running multiple concurrent Chrome instances.

When `lowMemoryMode` is active and the user hasn't passed `--workers`, the orchestrator:
- **skips auto-worker calibration** — no throwaway second Chrome just to time 5 frames;
- **pins to a single worker** — so the probe Chrome is reused for capture, never N concurrent;
- **prefers screenshot capture over BeginFrame** — avoids the BeginFrame protocol-timeout → relaunch churn on slow hardware;
- logs a one-line explanation of what it did and how to override.

Builds on #1221 (merged), which fixed the calibration timeout cap, the `<= 8192` boundary, and added the CLI timeout flags.

## Why

Reported in #1218 / #1219: renders on 8 GB laptops sit at low progress for minutes or stall. Root cause (per the triage thread) is architectural — the default pipeline launches up to 4 Chrome instances sequentially/overlapping (probe, calibration, capture, screenshot-fallback), each ~256 MB+, on machines with ~3 GB free. The concurrent browsers drive memory pressure that makes every CDP call slow and spikes V8 GC pauses.

#1221 made the timeouts and memory flags *apply correctly*; this PR removes the expensive shape entirely on the machines that can't afford it, rather than tuning it. "Smarter by default."

## How

- **`packages/engine/src/services/systemMemory.ts`** (new): one shared `isLowMemorySystem()` / `getSystemTotalMb()`, de-duplicating the `totalmem()` reads previously copied in `config.ts` and `browserManager.ts`. Threshold is inclusive (`<= 8192 MB`) — real "8 GB" hardware reports ~7600–8192 MB after firmware/iGPU reservations, so a strict `<` would skip the optimisation on the very hardware that needs it.
- **`config.ts`**: new `lowMemoryMode` field on `EngineConfig`, resolved tri-state — explicit override → `PRODUCER_LOW_MEMORY_MODE` (on/off) → auto-detect from total RAM.
- **`renderOrchestrator.ts`**: gate calibration off, pin workers to 1, force screenshot capture, and emit a safe-mode log line when `lowMemoryMode` is set and `--workers` is absent.
- **`render.ts`**: `--low-memory-mode` / `--no-low-memory-mode` override (sets the env var the producer's `resolveConfig` reads) + docs table entry.

Fully overridable: an explicit `--workers N` restores calibration-free parallelism; `--no-low-memory-mode` / `PRODUCER_LOW_MEMORY_MODE=false` restores the full default shape.

### Deliberately deferred (separate PRs)
- **Reuse the probe session for calibration**: only executes on the tier *above* 8 GB (safe-mode skips calibration on the target boxes). A correct BeginFrame-mode reuse would lose calibration's fast-fail-to-screenshot timeout — real risk on a path the reported scenario never hits. Better scoped on its own.
- **Retuning `calculateOptimalWorkers`'s `totalmem*0.5/256` memory model**: hot path for *all* renders incl. servers/Lambda, outside this PR's local-laptop scope.

## Test plan

- [x] Unit tests added/updated — `systemMemory.test.ts` (8192 boundary cases), `config.test.ts` (tri-state env resolution + explicit-override precedence). Engine suite passes (25 relevant tests).
- [x] `tsc` clean across engine/producer/cli; `oxlint` + `oxfmt` clean; removed an unused export so the `fallow --fail-on-issues` dead-code gate stays green.
- [x] Documentation updated — `docs/packages/cli.mdx` render-flags table.
- [ ] Manual testing on a real ≤ 8 GB host — not yet run; behaviour is unit-covered and the safe path (1 worker + screenshot) is already a supported render shape.

Note: one pre-existing producer test (`rejects a maliciously crafted key…`) fails identically on `main` — environment-specific path test, unrelated to this change.
2026-06-05 16:24:03 -07:00
Miguel Ángel a7cc9161a7 chore: release v0.6.74 2026-06-05 18:41:55 -04:00
Vance Ingalls 1324de54a8 fix(cli): bind studio preview server to loopback by default (#1210)
## Summary

- Binds the Studio preview server (`packages/cli`) to `127.0.0.1` instead of `0.0.0.0` so it is only reachable from localhost.
- Adds a `--host` flag for callers that genuinely need to expose the server on a wider interface (e.g. Docker, remote dev boxes).

## Security

**F-001 HIGH** — Studio preview server was binding on all interfaces, making it reachable from any network the developer's machine was on (including shared Wi-Fi, corp LAN). Because the server serves the project filesystem under no auth, any peer on the same network could read arbitrary project files. Restricting to loopback closes this exposure for the default case.

## Test plan

- [x] `hyperframes preview` starts — server reachable on `localhost:<port>`, not on LAN IP
- [x] `hyperframes preview --host 0.0.0.0` still binds on all interfaces for Docker / remote-dev use cases
- [x] Existing unit tests pass
2026-06-05 14:48:22 -07:00
Miguel Ángel 20894ab9a3 fix: respect user timeouts on low-memory systems (#1221)
Closes #1219

## Problem

On 8GB RAM machines, renders time out at 5% with `Runtime.callFunctionOn timed out` during the duration probe. User-set timeout env vars (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`) are silently ignored by the calibration path, and there are no CLI flags to control timeouts directly.

## Root causes

1. **Calibration timeout cap overrides user settings** — `createCaptureCalibrationConfig` used `Math.min(cfg.protocolTimeout, 30_000)`, meaning even if the user set 300s, calibration still capped at 30s. On slow hardware this causes unnecessary timeouts.

2. **8GB systems get no low-memory treatment** — `getLowMemoryFlags()`, `getGpuMemBudgetMb()`, `memoryAdaptiveCacheLimit()`, and `memoryAdaptiveCacheBytesMb()` all used `< 8192` as the threshold. Systems reporting exactly 8192 MB (common for 8GB machines) fell through to the "plenty of memory" path, getting no Chrome heap reduction or cache limits.

3. **No CLI flags for key timeouts** — Users had to discover the correct env var names (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`, `PRODUCER_PLAYER_READY_TIMEOUT_MS`) by reading source. The non-existent `PUPPETEER_PROTOCOL_TIMEOUT` and `--browser-timeout` were common guesses that did nothing.

## Changes

- `captureCost.ts`: `Math.min` → `Math.max` so the 30s calibration default is a floor, not a ceiling. User-set higher timeouts are now respected.
- `browserManager.ts`: `>= 8192` → `> 8192` in `getLowMemoryFlags()` and `<= 8192` in `getGpuMemBudgetMb()` so 8GB systems get reduced Chrome heap and GPU memory budget.
- `config.ts`: `< 8192` → `<= 8192` in `memoryAdaptiveCacheLimit()` and `memoryAdaptiveCacheBytesMb()` so 8GB systems get reduced frame cache limits.
- `render.ts`: Added `--protocol-timeout <ms>` and `--player-ready-timeout <ms>` CLI flags, wired through `resolveConfig` overrides.
- Updated calibration tests to match the new floor-not-ceiling behavior.
- Added fallow suppressions for pre-existing unused exports in `captureCost.ts`.

## Test plan

- [x] Engine config tests pass (`vitest run src/config.test.ts`)
- [x] Browser manager tests pass (`vitest run src/services/browserManager.test.ts`)
- [x] Calibration safeguard tests pass (4/4 in `renderOrchestrator.test.ts`)
- [x] TypeScript compiles cleanly for engine and cli packages
- [ ] CI pipeline
2026-06-05 15:28:56 -04:00
Miguel Ángel 2757949912 chore: release v0.6.73 2026-06-04 22:52:23 +00:00
Miguel Ángel 9679503158 fix(cli): report available memory instead of free memory in doctor (#1204)
os.freemem() on macOS returns only truly free pages (~0.1 GB on a 24 GB
machine), ignoring inactive/purgeable/speculative pages the kernel
reclaims on demand. This caused a false "Low memory" warning on every
macOS machine.

Add getAvailableMemoryMb() that uses vm_stat on macOS and MemAvailable
from /proc/meminfo on Linux, falling back to os.freemem() elsewhere.

Also trim FFmpeg/FFprobe version strings to just "toolname X.Y.Z"
instead of the full copyright line.
2026-06-04 18:23:02 -04:00
Carlos Alcaraz GregorandCarlos Alcaraz 0870394d20 test(cli): cover the cloud client 401-refresh-retry decorator (#1202)
createCloudClient wraps the generated client in a Proxy that catches
HyperframesApiError(401), force-refreshes credentials, and retries
once. That auth recovery path had no tests; a regression would only
surface as cloud commands failing outright on server-side token
revocation or clock-skew rejections.

Covers: passthrough, refresh-and-retry with the new token actually
re-resolved (not a stale header replay), refresh failure surfacing
the original 401, single-retry on repeated 401, and no refresh on
non-401 or transport errors. Zero source changes.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-06-04 15:12:19 -07:00
James RussoandClaude Opus 4.7 6affe2d212 fix(cli): reject directory --composition and add --browser-timeout (#1199) (#1200)
* fix(cli): reject directory --composition and add --browser-timeout (#1199)

Two unrelated symptoms from issue #1199, fixed together:

1. `--composition .` (or any directory path) used to slip past the
   existsSync check in render.ts and explode downstream as
   `EISDIR: illegal operation on a directory, read` when the producer
   readFileSync'd the entry. The CLI now treats `.` / `""` as "omit
   the flag" (falls back to index.html) and rejects other directory
   paths with an actionable error pointing at the .html shape.

2. The 60s Puppeteer page.goto timeout in frameCapture.ts was hard-
   coded, so heavy compositions (many videos / fonts / asset requests)
   could not complete `domcontentloaded` in time. Add a configurable
   `pageNavigationTimeout` to EngineConfig (default 60_000, env
   fallback PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS) and expose it as
   `--browser-timeout <seconds>` on `hyperframes render`. The flag
   threads through both renderLocal (via resolveConfig) and the
   docker bridge (via buildDockerRunArgs).

Tests:
- render.test.ts: forwards/omits pageNavigationTimeout into resolveConfig
- dockerRunArgs.test.ts: forwards/omits --browser-timeout (seconds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): address PR #1200 review — extract validators, tighten bounds

Addresses Vai's blockers and Miguel's nits on PR #1200:

- Vai blocker 1 (fallow CRAP) + blocker 3 (no argv tests):
  Extract --browser-timeout and --composition validators into pure
  helpers in utils/renderArgs.ts with a structured-result discriminant.
  Drops ~45 lines of inline validation from run(), reducing its CRAP
  score 1290→978 and cyclomatic 75→65. 19 new unit tests cover the
  parse branches (sub-ms, overflow, NaN, Infinity, empty, negative,
  ".", "./", whitespace, directory, missing, ../escape, sibling-prefix).

- Vai blocker 2 (sub-ms → timeout:0 = "no timeout"): reject inputs
  that round to <1 ms. Puppeteer treats page.goto({timeout:0}) as
  wait-forever, so --browser-timeout 0.0004 silently flipped the
  semantics. Now rejected with an explicit "rounds to 0 ms" error.

- Vai important 5 (1e10 accepted → setTimeout overflow): cap at
  86_400s (24h). Above Node's TIMEOUT_MAX ≈ 2^31-1 ms setTimeout
  fires immediately, the opposite of "long timeout."

- Vai important 4 (related timeouts unmentioned): CLI help and docs
  now flag PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS and the 45s
  playerReadyTimeout as the other knobs heavy compositions may need.

- Vai nit 7 (s/ms unit mismatch): help text and docs row both call
  out the SECONDS-vs-MILLISECONDS difference between flag and env.

- Vai nit 8 / Miguel nit (composition flag discoverability): the
  --composition description now says "Pass `.` (or omit the flag)
  to render the project's index.html."

- Miguel nit (dead branch): the entryFile === "" unreachable branch
  is gone. New helper uses `if (!trimmed || trimmed === ".")`.

Also adds a trailing-separator guard on the project-containment check
(sibling-prefix bypass: /proj-evil/x.html no longer slips past
startsWith('/proj')) — flagged by the code review.

The three remaining fallow complexity findings on render.ts (run,
renderDocker, trackRenderMetrics) are inherited from main; this PR
reduces run() but does not refactor it. Suppressed with
fallow-ignore-next-line markers and inline rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): diverge --browser-timeout error messages per Vai nit 5

The `not-a-number` and `not-positive` branches in browserTimeoutErrorMessage
shared the generic "Must be a positive number of seconds" message even
though the discriminant carried distinct kinds. Diverge them so users see
the specific failure mode:

  --browser-timeout abc   →  "Got \"abc\", which is not a number."
  --browser-timeout -5    →  "Got \"-5\" seconds, which is not positive."

The shared hint ("pass a positive number of seconds, e.g. 180") is
preserved on both branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:47:10 -04:00
James 0b98565039 chore: release v0.6.72 2026-06-04 07:38:21 +00:00
James Russo 2be41937a9 fix(cli): support arm64 hosts for --docker render (#1196)
* fix(cli): support arm64 hosts for `--docker` render

The Docker render path pinned `--platform linux/amd64` for both build
and run, which on Apple Silicon / Graviton forced qemu emulation of
chrome-headless-shell. The emulated chrome process either SEGV'd or
hung on page navigation, producing the failures reported in #1193 /
#1194 / #1195.

Derive the platform from `process.arch` instead. On arm64 hosts:

- The image builds natively (no qemu).
- The Dockerfile skips the chrome-headless-shell install because
  Chrome for Testing only publishes a `linux64` build (verified
  against the known-good-versions manifest).
- The wrapper script leaves `PRODUCER_HEADLESS_SHELL_PATH` unset
  when no headless-shell binary is present, so the engine falls
  back to the system chromium that the Dockerfile already
  installs from apt and points at via `PUPPETEER_EXECUTABLE_PATH`.

`TARGETARCH` is forwarded as an explicit `--build-arg` instead of
relying on BuildKit's automatic platform args — the legacy
builder (and some BuildKit configs, including colima on macOS)
leaves it unset, which would silently bypass the arch conditional
in the Dockerfile.

Image tags are now suffixed with `-arm64` on arm64 hosts so amd64
and arm64 images of the same hyperframes version can coexist in
the local cache.

The arm64 path renders correctly but loses byte-for-byte parity
with amd64 (system chromium uses screenshot capture, not
HeadlessExperimental.beginFrame). The CLI prints a one-line
warning so users comparing against amd64 baselines know.

Verified on macOS 26.5 / M4 Max:

- Before: `qemu: unknown option 'type=gpu-process'` followed by a
  chrome-headless-shell SIGSEGV after ~4 minutes.
- After: 300/300 frames captured in ~18s of render time (1m18s
  wallclock including a one-time image build), MP4 produced.

Closes #1193
Closes #1194
Closes #1195

* fix(cli): address review feedback on docker arm64 fix

Follow-up to 61880cdc. Addresses one substantive review comment from
@vanceingalls and three self-review gaps.

1. Restore loud build failure on amd64 when chrome-headless-shell is
   missing (per @vanceingalls). The original Dockerfile used an `&&`
   chain that crashed the build if `find` returned empty; the new
   `if/else` wrapper silently fell through to system chromium even on
   amd64, which would mask golden-baseline regressions from a future
   @puppeteer/browsers cache layout change. The else branch now checks
   `TARGETARCH = amd64` and exits 1 with an actionable error, while
   arm64 still falls through to the system-chromium wrapper cleanly.

2. Add `HYPERFRAMES_DOCKER_PLATFORM` env override. The fix derives
   platform from `process.arch`, which silently picks the wrong arch
   in three real-world cases: x64 Node under Rosetta on Apple Silicon
   (re-triggers issue #1193), parity-regen for amd64 golden baselines
   on an arm64 host, and DOCKER_HOST pointing at a remote daemon with
   a different arch. Empty/whitespace override is a no-op (falls back
   to arch detection) so `export FOO=""` doesn't pin platform to "".

3. Fail fast when `--gpu` is requested on arm64. Docker Desktop on
   Apple Silicon doesn't implement `--gpus` passthrough; the previous
   code would crash at `docker run` with an opaque device-driver
   error. We now short-circuit with errorBox pointing at the env
   override as the workaround.

4. Close the test gap on the default-arch resolution. Every previous
   test passed `arch` explicitly; a refactor that dropped the
   `= process.arch` default would pass all tests but break every arm64
   host at runtime. Added one assertion that calls
   `resolveDockerPlatform()` with no args, plus coverage for the env
   override.

The new arm64 platform-checking logic is extracted into
`resolveDockerHostPlatform()` so `renderDocker` itself stays focused
on the build/run wiring (and below the fallow complexity gate).

Test plan:
- `bunx vitest run packages/cli/src/utils/dockerRunArgs.test.ts` — 31 passed (was 27).
- `bunx vitest run packages/cli` — 647 passed (was 643).
- E2E on macOS 26.5 / M4 Max: deleted the cached arm64 image, ran
  `--docker --quality draft --workers 1` against the blank scaffold —
  300/300 frames in 1m1s wallclock, MP4 produced.
2026-06-04 03:13:44 -04:00
Miguel Ángel c6a91ab0e4 chore: release v0.6.71 2026-06-04 03:11:40 +00:00
Miguel Ángel 8c6faa45b5 fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash (#1185)
* fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash

Convert the static `import { ... } from "@puppeteer/browsers"` in
browser/manager.ts to dynamic imports inside the async functions that
use them. This eliminates a module-load-time crash when the transitive
`debug` dependency is missing or corrupted.

Previously, every CLI command (including init, lint, docs, help) would
crash with "Cannot find package debug" if the debug package was absent —
even though only browser-related commands need @puppeteer/browsers.

Also add `debug` as a direct dependency so npm/bun always installs it
explicitly rather than relying on transitive resolution.

PostHog data: ~3,955 total-CLI-crash occurrences since May 29.

* fix(cli): simplify isLinuxArm to sync inline check and surface real load error

isLinuxArm() was async only to call detectBrowserPlatform() from
@puppeteer/browsers, but that function just checks process.platform +
process.arch under the hood. Replace with a direct inline check and make
the function sync — no behavioral change, removes an unnecessary async
boundary and an eager load of the package we're trying to lazy-load.

Also surface the real error from loadPuppeteerBrowsers() catch block instead
of hard-coding 'likely missing transitive dependency "debug"' — the actual
cause could be anything (missing package, corrupt install, wrong Node ABI).
2026-06-03 22:53:16 -04:00
Miguel Ángel cabd0616ea fix(cli): suppress EPIPE crashes in piped agent environments (#1184)
* fix(cli): suppress EPIPE crashes in piped agent environments

When the CLI runs inside a piped environment (Claude Code, Codex,
Cursor), the reader may close the pipe before we finish writing.
Node treats EPIPE on stdout/stderr as an uncaughtException, crashing
the process with a non-zero exit code.

Add stream-level EPIPE handlers on stdout/stderr at the top of the
entry point (before any output) and make the uncaughtException handler
EPIPE-aware so it exits cleanly (code 0) instead of crash-reporting.

PostHog data: ~10,300 EPIPE errors over 10 days, contributing to the
preview command's 43-59% failure rate in agent environments.

* fix(cli): set commandFailed before EPIPE exit to prevent false success telemetry

EPIPE is a pipe-reader-closed signal, not a successful run. The exit handler
uses 'code === 0 && !commandFailed' to determine success — without setting
commandFailed=true before process.exit(0), every EPIPE exit was recorded as
success:true in telemetry.

Moves the commandFailed declaration to the top of the file so the stream-error
EPIPE handlers (which must run before any writes) can reference it. Also sets
commandFailed=true in the uncaughtException EPIPE path for the same reason.
2026-06-03 22:53:08 -04:00
Miguel ÁngelandJefsky Wong 6de6ea5349 fix: delay ObjectURL revocation and silence TS5 baseUrl deprecations (#1181)
- Delay URL.revokeObjectURL() from 0ms to 1000ms in useFrameCapture so
  the browser has time to initiate the download before the blob is freed.
  A 0ms timeout fires synchronously after the current microtask queue,
  before the browser's download machinery reads the URL.

- Add ignoreDeprecations: '5.0' to cli and studio tsconfigs to silence
  TypeScript baseUrl/paths deprecation warnings without changing behavior.

Co-authored-by: Jefsky Wong <jefsky@qq.com>
2026-06-03 20:38:40 -04:00
Miguel Ángel f5d81cb5a7 chore: release v0.6.70 2026-06-03 04:41:52 +00:00
Miguel Ángel a4706da513 fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

localizeExternalAssets() now scans all HTML and CSS files in the archive
for src, href, and url() references that resolve outside the project
dir. For each, it copies the file into the archive under _ext/ and
rewrites the reference to point there.

Handles: src/href attributes, <style> url(), inline style url(),
standalone CSS url(), sub-composition HTML files, deduplication of
the same asset referenced from multiple files.

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
2026-06-01 21:06:35 -04:00
Miguel Ángel 598e3e957a chore: release v0.6.69 2026-06-01 20:14:01 -04:00
Miguel Ángel bda4a32a7e chore: release v0.6.68 2026-06-01 20:05:12 -04:00
James f37e3b993e chore: release v0.6.67 2026-06-01 22:28:28 +00:00
James RussoandClaude Opus 4.8 42ad305073 feat(cli): validate cloud render aspect/composition/format before upload (#1156)
* feat(cli): validate cloud render aspect/composition/format before upload

`hyperframes cloud render` accepted inputs the render pipeline can't
satisfy and only failed server-side with a generic message. Add three
client-side, pre-upload checks:

- Missing `--composition` entry → clean "Composition not found" error
  instead of uploading a zip the render rejects opaquely.
- Explicit `--aspect-ratio` that conflicts with the composition's
  authored data-width/data-height → "Aspect ratio mismatch" error.
  Aspect ratio is derived from the composition (auto-detected for local
  dirs), so the flag is rarely needed and can't reshape — only match.
- `--resolution 4k` with `--format webm|mov` → rejected, since the alpha
  capture path can't supersample.

Replaces maybeAutoDetectAspectRatio with resolveAspectRatioForSubmit,
which folds detection + explicit-flag validation into one pass. Both new
validators are exported and unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): reject explicit --aspect-ratio on unsupported-ratio compositions

Addresses review on #1153.

The mismatch guard only fired for `matched` compositions. For a composition
whose dims resolve to an unsupported ratio (e.g. 4:5 → detection `no-match`),
a conflicting explicit `--aspect-ratio` silently passed through and was
forwarded to the server, which rejected it later — the opposite experience
from a `matched` composition with the same wrong flag.

Extend the guard to the `no-match` case: dims are known and the ratio can
never equal a supported (16:9/9:16/1:1) explicit value, so it's a definite
conflict. Kinds with unknown dims (no-dims/no-root-div/invalid-dims/read-error)
still forward the explicit value since a conflict can't be proven. +1 test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:09:19 -04:00
Miguel Ángel a01a266efa fix(cli): mock findFFmpeg in render tests for CI without ffmpeg (#1154)
* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors

Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.

* fix(cli): consolidate FFmpeg pre-flight into renderLocal()

Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.

* fix(cli): mock findFFmpeg in render tests for CI runners without ffmpeg
2026-06-01 16:55:04 -04:00
Miguel Ángel cfef6caf5f chore: release v0.6.66 2026-06-01 20:48:20 +00:00
Miguel Ángel 5697e4adc3 fix(cli): pre-flight FFmpeg check and propagate render failure stage (#1149)
* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors

Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.

* fix(cli): consolidate FFmpeg pre-flight into renderLocal()

Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.
2026-06-01 16:44:49 -04:00
Miguel Ángel 9ead3a83b5 chore: release v0.6.65 2026-06-01 13:50:51 +00:00
James Russo 3c7e2f3649 feat(cli): auto-detect aspect_ratio from composition dims when --aspect-ratio is omitted (#1145)
When the user runs `hyperframes cloud render` without `--aspect-ratio` and
the project source is a local directory, parse the entry HTML's root
`<div data-composition-id ...>` for `data-width` / `data-height` and pick
the supported aspect ratio that matches within ±0.05 tolerance:

- 16:9 (≈1.778) ← landscape 1920×1080, 4K 3840×2160, etc.
- 9:16 (≈0.563) ← portrait 1080×1920
- 1:1 (=1.0)    ← square 1080×1080

If the composition's ratio matches one of these, the CLI sets
`aspect_ratio` in the submit body and prints a one-line note
(`Detected aspect ratio: 9:16 (from index.html dims 1080×1920)`).

If the composition has no root div, no dims, or a ratio outside all three
tolerance bands (e.g. 4:5, 5:4, 21:9), the CLI logs a one-line warning
explaining the fallback and leaves `aspect_ratio` out of the submit body
— the server defaults to 16:9, and the user can pass `--aspect-ratio`
explicitly to override.

Explicit `--aspect-ratio` always wins. Detection is skipped for
`--asset-id` / `--url` project sources since the composition isn't on
disk; user gets a brief note in that case too.

New helper: `packages/cli/src/cloud/detectAspectRatio.ts` (pure regex
parse, no DOM library dep). 23 tests cover canonical matches, in-band
tolerance, all three non-match patterns (no root div, no dims, ratio out
of bands), and authoring edge cases (unquoted attrs, attribute order,
self-closing tags, multi-composition files).

Closes the `auto` carve-out flagged in ef#38182's deferred-scope note —
the CLI gets auto-detect without requiring a server-side zip-parse
capability (no API change).
2026-05-31 21:06:18 -04:00
James Russo 8e0b26dab6 feat(cli): split cloud render --resolution into --aspect-ratio + --resolution (#1143)
Aligns the `hyperframes cloud render` CLI with the v3 API's decomposed
shape (ef#38182). Replaces the flat 6-value `--resolution` flag with two
independent flags:

- `--resolution`: tier ∈ {1080p, 4k}; default 1080p; 4k bills at 1.5x
- `--aspect-ratio`: ratio ∈ {16:9, 9:16, 1:1}; default 16:9

Regenerates `packages/cli/src/cloud/_gen/{types,client}.ts` from the
updated `experiment-framework/openapi/external-api.json`. Threads
`aspectRatio` through `SubmitOptions` and `buildRenderBody` so it lands
in the request body as `aspect_ratio`.

Old flag values (`landscape`, `portrait-4k`, etc.) now reject at the CLI
layer via `parseEnumFlag`, matching the API surface's rejection. The
six legacy combinations map to the same effective output in the new
shape — see the migration table in ef#38182's PR body.

Deferred (will follow in a separate PR): 720p, 4:5, 5:4, and `auto`.
These need producer-side capability + controller-side composition-dim
inference; out of scope for an API/CLI shape refactor.
2026-05-31 20:41:25 -04:00
Miguel Ángel a7b874326a chore: release v0.6.64 2026-05-31 13:56:46 +00:00
Carlos Alcaraz GregorandCarlos Alcaraz f8abff2e1c test(cli): cover cloud reportApiError hint cascade (#1131)
reportApiError centralizes the HyperframesApiError -> Error -> String
reporting cascade for the cloud subverbs, including the curated
ERROR_CODE_HINTS table and its priority order (code-specific hint >
caller suggestion > bare code label > no third line). That priority
logic was previously untested; the module comment notes a past
regression where hyperframes_render_not_found was unreachable from
get/delete.

Add errors.test.ts covering: 404 + notFound short-circuit, known-code
hint, hint-wins-over-suggestion priority, suggestion fallback, bare
code label, no-third-line, extraHints merge and override, plain Error,
and non-Error stringification. Mocks errorBox and process.exit
following the sibling cloud/parsing.test.ts pattern.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-30 22:38:22 -04:00
Miguel Ángel 4f1f99ef1d chore: release v0.6.63 2026-05-30 17:28:36 +00:00
Miguel Ángel 31441fc752 chore: release v0.6.62 2026-05-30 13:11:49 +00:00
Miguel Ángel 13b1bffe01 chore: bump version to 0.6.61 2026-05-29 23:36:15 -04:00
Miguel Ángel d32c8dcb6f chore: release v0.6.60 2026-05-30 02:22:16 +00:00
Miguel Ángel 307e391d91 chore: release v0.6.59 2026-05-29 23:32:38 +00:00
James Russo f53f4a7a08 fix(cli): drop misleading hint on hyperframes_project_invalid (#1127) 2026-05-29 18:22:32 -04:00
Miguel Ángel 30c8344651 chore: bump version to 0.6.58 2026-05-29 13:18:51 -04:00
Miguel Ángel 43c56ee476 chore: bump version to 0.6.57 2026-05-29 10:34:08 -04:00
Miguel Ángel bc3701f590 chore: bump version to 0.6.56 2026-05-28 23:51:08 -04:00
Miguel Ángel b1f9587aa1 chore: bump version to 0.6.55 2026-05-28 20:58:58 -04:00
Miguel Ángel 2f3ab9f4c9 chore: bump version to 0.6.54 2026-05-28 19:18:34 -04:00
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00
Miguel Ángel e16f916448 chore: bump version to 0.6.53 2026-05-28 17:06:50 -04:00
James Russo 8cd74c1e8c fix(cli): cloud delete --no-confirm and cloud render --no-wait (#1112)
Both flags were silently broken via the same root cause: citty parses
`--no-FOO` as a negation of the base flag `FOO`, so a flag literally
named "no-confirm" gets routed as `args.confirm=false` (not
`args["no-confirm"]=true`), and same for "no-wait".

Surfaced during the end-to-end smoke test on the just-merged stack:

- `cloud delete <id> --no-confirm` was hitting "Confirmation required"
  and exiting 1 without calling the API.
- `cloud render --no-wait` was running the full poll + download flow
  instead of submitting and exiting with the render_id.

Renamed the arg keys to `confirm` (default true) and `wait` (default
true) so citty's built-in negation handles the user-facing flags
correctly. Flag names stay the same; only the runtime arg keys change.

Live-tested both: delete now removes the render and a subsequent get
404s; --no-wait now returns just {render_id, status: "queued"} and
exits.

Note: a third instance of the same pattern exists in commands/add.ts
(`--no-clipboard`) and is also latently broken. Out of scope for this
fix; should be addressed alongside any audit of the CLI's interactive-
vs-noninteractive defaults.
2026-05-28 11:45:47 -07:00
James Russo ce5e872e51 feat(cli): add hyperframes cloud render/list/get/delete commands (#1110)
* feat(cli): vendor initial hyperframes cloud client codegen

Generated by experiment-framework/scripts/generate_hyperframes_cli_client.py
(see heygen-com/experiment-framework#37896). Sets up the baseline for the
sync workflow to diff against on future spec changes.

The follow-up PR adds the orchestration layer (zip + upload + poll +
download) and the user-facing 'hyperframes cloud render/list/get/delete'
commands on top of this generated client.

The fallow ignore pattern is necessary because the generated request()
method is intentionally a single switch that handles all 5 endpoints
in one place; refactoring it here would just be re-introduced on the
next codegen run.

* chore(cli): regenerate cloud client with mimeType parameter on multipart uploads

Adds optional mimeType arg to uploadAsset (and any future multipart
endpoints). Without it, FormData sends application/octet-stream which
is correct for the documented media surface (png/jpeg/mp4/etc.) but
ambiguous for the private-beta zip uploads the cloud render flow uses.
Callers that pass `mimeType: "application/zip"` tag the multipart
part with the right Content-Type so downstream proxies, WAFs, and any
future server-side change that keys off the part MIME (instead of the
current magic-byte detection) all see the intended type.

Addresses review feedback on heygen-com/experiment-framework#37896.
Generated by scripts/generate_hyperframes_cli_client.py with the
matching update to the multipart emit path.

* feat(cli): add hyperframes cloud render/list/get/delete commands

Hand-rolled orchestration layer on top of the auto-generated cloud
client (vendored in the previous PR):

- cloud render <dir>: zip via createPublishArchive → upload to
  /v3/assets → submit /v3/hyperframes/renders → poll
  /v3/hyperframes/renders/{id} every 10s (max 60min) → stream the
  signed video_url to disk.
- cloud render --no-wait: submit and exit with the render_id.
- cloud render --asset-id / --url: skip zip+upload and use a
  pre-uploaded asset or public HTTPS zip.
- cloud render --variables / --variables-file: same UX as the local
  render command; variables are validated against
  data-composition-variables only when there's a local project.
- cloud list / cloud get / cloud delete: thin wrappers around the
  matching client methods, with cursor-pagination support on list.

Auth comes from the existing cli/src/auth/ chain via cloud/auth.ts —
no new credential store, no new env var. The cloud client receives a
getAuthHeaders() callback that re-resolves credentials on every
request, so OAuth refreshes mid-poll are picked up automatically.

Also extracts a parent-scoped path lookup in help.ts so 'cloud render
--help' surfaces the right examples instead of falling through to the
top-level 'render' command's examples.

* fix(cli): address 15 code-review findings on cloud commands

Correctness fixes
- delete: require --no-confirm when stdin isn't a TTY OR --json is
  passed; previously both silently auto-bypassed the irreversible-
  delete prompt. Explicit decline now exits 2 (distinct from API/system
  errors which still exit 1).
- render: mutex check now counts the positional dir alongside
  --asset-id / --url; `cloud render ./foo --asset-id X` now errors
  instead of silently dropping the dir.
- render: docstring updated — only --no-wait short-circuits the poll
  loop; --callback-url is independent (webhook fires either way).
- render: removed dead try/catch around resolveProject (it calls
  process.exit, never throws). resolveVariablesAndValidateIfLocal also
  takes the resolved project source instead of re-parsing args.
- render: createPublishArchive errors now surface via errorBox instead
  of bubbling a raw stack trace past citty.
- help: loadExamples now only catches ERR_MODULE_NOT_FOUND; real load
  errors (syntax error, broken import) propagate so a broken
  cloud/render.ts no longer silently shows the local render command's
  examples. Also skips the parent-scoped lookup when parentName is the
  root command ("hyperframes").
- list: fetchAll gained a 50-page safety cap + duplicate-cursor
  detection so a buggy backend serving the same next_token on a loop
  can't OOM the CLI.
- download: drain await now listens for error / close / abort so a
  failing write stream (ENOSPC, AbortSignal) rejects promptly instead
  of hanging forever. Partial files are unlinked on any error so the
  caller never observes a truncated MP4. content-length is verified
  against the actual byte count.
- poll: default sleep is abort-aware so Ctrl+C feels immediate instead
  of waiting out the full interval.
- pollWithProgress: ANSI carriage-return redraws now gated on
  process.stdout.isTTY — non-TTY runs (CI, file redirects) emit one
  line per status transition instead of polluting the log with
  literal escape codes.

Cloud client: 401-retry-with-refresh
- createCloudClient now wraps the generated client with a Proxy that
  catches HyperframesApiError(status=401), force-refreshes the OAuth
  token via forceRefreshCredentials, and retries the call exactly
  once. Mirrors AuthClient's onUnauthenticatedRefresh so server-side
  revocations and clock-skew rejections recover automatically.
- auth.ts gained forceRefreshCredentials() and now updates expires_at
  on the refreshed credential it returns (fixed stale-expiry race).

Shared helpers
- cloud/errors.ts: reportApiError(stage, err, opts) is the single
  error-funnel. ERROR_CODE_HINTS now applies to every subverb — fixes
  hyperframes_render_not_found being unreachable from get/delete and
  cuts ~70 LOC of duplicated try/catch/instanceof from render/list/
  get/delete.
- cloud/parsing.ts: parseIntFlag / parseNumericFlag / parseEnumFlag
  strict-mode parsers reject trailing garbage that Number.parseInt
  silently accepts.
- cloud/ansi.ts: stripAnsi / visibleLength / padEndVisible — covers
  ESC + 24-bit truecolor (c.accent palette) instead of the previous
  regex which undercounted overhead and missed truecolor.

JSON-output consistency + _meta envelope
- Every cloud subverb's --json output now goes through withMeta(...)
  so it carries the standard _meta envelope documented in cli.mdx.
- Single-render outputs use {render: detail} across get, delete,
  render-no-wait, render-failed, and render-success. list uses
  {renders: [...], has_more, next_token?}. delete adds deleted: true.

Tests
- 25 new tests across ansi.test.ts, parsing.test.ts, plus truncation
  + abort-cleanup tests for download.test.ts.
- 589 / 589 total CLI tests pass.

* fix(cli): address Vai's review on cloud commands

- render: pass mimeType: "application/zip" to uploadAsset so the
  multipart Content-Type is correct (was application/octet-stream).
  Server currently magic-byte-detects from file bytes so this is
  belt-and-suspenders today, but any downstream proxy / WAF / future
  server change that keys off the part MIME now sees the intended
  type instead of relying on detection.
- render: poll error path now surfaces "Resume with: hyperframes
  cloud get <renderId>" via reportApiError's new `suggestion`
  option, matching the PollTimeoutError handler. The server-side
  render keeps running through a transient 5xx; the user just
  needs the right command to pick it back up.
- list: fetchAll now errorBox-exits on the malformed
  {has_more: true, next_token: null} shape instead of silently
  returning a truncated list (matching the duplicate-cursor guard).
- download: closeFile now listens for 'error' on the write stream
  in addition to the end() callback, so a late ENOSPC during flush
  doesn't leak an unhandled error onto the stream and resolves the
  finally promptly.
- errors: reportApiError accepts an optional `suggestion` that's
  used as the errorBox third line when no code-specific hint
  matches — gives callers a place to surface always-actionable
  recovery context.
- docs(cli): document --idempotency-key as the safe-retry mechanism
  for the upload step. The 401-retry Proxy replays POST requests
  on a stale token; without an idempotency key, the upload may
  land twice. A UUID per logical render is the recommended pattern.
2026-05-28 14:10:05 -04:00
James Russo e9f45b7c33 feat(cli): vendor initial hyperframes cloud client codegen (#1109)
* feat(cli): vendor initial hyperframes cloud client codegen

Generated by experiment-framework/scripts/generate_hyperframes_cli_client.py
(see heygen-com/experiment-framework#37896). Sets up the baseline for the
sync workflow to diff against on future spec changes.

The follow-up PR adds the orchestration layer (zip + upload + poll +
download) and the user-facing 'hyperframes cloud render/list/get/delete'
commands on top of this generated client.

The fallow ignore pattern is necessary because the generated request()
method is intentionally a single switch that handles all 5 endpoints
in one place; refactoring it here would just be re-introduced on the
next codegen run.

* chore(cli): regenerate cloud client with mimeType parameter on multipart uploads

Adds optional mimeType arg to uploadAsset (and any future multipart
endpoints). Without it, FormData sends application/octet-stream which
is correct for the documented media surface (png/jpeg/mp4/etc.) but
ambiguous for the private-beta zip uploads the cloud render flow uses.
Callers that pass `mimeType: "application/zip"` tag the multipart
part with the right Content-Type so downstream proxies, WAFs, and any
future server-side change that keys off the part MIME (instead of the
current magic-byte detection) all see the intended type.

Addresses review feedback on heygen-com/experiment-framework#37896.
Generated by scripts/generate_hyperframes_cli_client.py with the
matching update to the multipart emit path.
2026-05-28 13:36:58 -04:00
Miguel Ángel d625dc8509 feat: post-render and Studio feedback collection via PostHog surveys (#1101)
* feat(cli): prompt for render satisfaction after successful renders

* feat: add text feedback, doctor context, and Studio render feedback UI

* feat(studio): replace render feedback with session-based Studio experience bar

Move the feedback prompt out of RenderQueueItem (where it triggered every
5th render) into a standalone StudioFeedbackBar mounted at the bottom of
the preview area. The new bar is session-gated (shows after the 5th studio
session), auto-dismisses after 20s, and respects a 30-day cooldown once
dismissed or submitted. Renames telemetry to trackStudioFeedback with a
"studio_experience" survey ID to reflect the broader scope.

* feat(studio): attach browser doctor summary to feedback events

* fix(studio): use recurring interval for feedback instead of one-time cooldown

* fix(cli): skip feedback prompt when an agent runtime is detected

* feat(cli): add hyperframes feedback command and agent render hint

- New `hyperframes feedback --rating <1-5> --comment "..."` command
  for submitting anonymous render satisfaction feedback via telemetry.
- When an AI agent runtime is detected after a render, print a dimmed
  hint to stdout so the agent can optionally call the command instead
  of silently skipping the readline prompt.
- Export getDoctorSummary from telemetry/feedback.ts to share the
  system-info collector between the interactive prompt and the CLI command.
- Register the command in cli.ts and help.ts under Settings.

* fix(studio): align feedback interval to every 15 sessions

* fix: show CLI feedback on first render, Studio every 10 sessions

* feat: add env flags to disable feedback prompts

* feat: env flags to configure feedback prompt frequency

* fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
2026-05-28 12:17:47 -04:00
James Russo b7b855845a feat(cli): add hyperframes auth OAuth (PKCE + loopback + refresh) (#1084)
## What

Adds OAuth 2.0 + PKCE login as the default for `hyperframes auth login`,
plus refresh-token + 401 auto-retry + `auth refresh`. Stacks on top of
PR #1081 (the API-key + shared store work).

- `hyperframes auth login` (no flags) — opens the user's browser to
  `/v1/oauth/authorize`, captures the code on an ephemeral
  `127.0.0.1:<port>/oauth/callback`, exchanges it for tokens with
  PKCE S256, and persists. `--api-key` opts back into the legacy
  long-lived-key path from PR #1081.
- `hyperframes auth refresh` — force-refresh the OAuth access token
  using the stored refresh_token. Mostly useful for testing the path.
- `hyperframes auth logout` — best-effort revokes via
  `POST /v1/oauth/revoke` (RFC 7009) before wiping local state.
- `AuthClient` now refreshes-and-retries once on a 401 when the
  caller wires `onUnauthenticatedRefresh`. `auth status` wires it.

Internals added in `packages/cli/src/auth/`:
- `pkce.ts` — RFC 7636 code_verifier + S256 code_challenge.
- `loopback.ts` — ephemeral 127.0.0.1 HTTP server; state validation,
  120s timeout, styled success/error page.
- `browser.ts` — wraps `open` with a `BROWSER=none` /
  `HF_NO_BROWSER=1` fallback that prints the URL.
- `oauth.ts` — `startAuthorizationCodeFlow`, `refreshTokens`,
  `revokeTokens`, `requireOAuthConfigured`, `parseTokenResponse`.

## Why

This is the foundation OAuth flow that lets free-tier users authenticate
without managing a long-lived key. Refresh + auto-retry means CLI
commands keep working past the access_token lifetime without bugging
the user.

The OAuth client_id (`q2A2QRSke2LrFTPJhoDbHtXh`) is the one James
created in the `oauth2_client` table. Baked in as a build-time default;
override via `HYPERFRAMES_OAUTH_CLIENT_ID` for dev/test.

## How

- Public client: PKCE only, no `client_secret`. Backend already
  requires PKCE (`movio/logic/oauth2.py:638`).
- Loopback port is ephemeral (`server.listen(0)`) — the backend
  wildcards localhost ports for public clients
  (`movio/model/oauth2.py:check_redirect_uri`), so the registered
  redirect URI's port is a placeholder.
- State parameter is generated per-flow + validated on callback to
  prevent CSRF.
- Token-response parsing is permissive on `expires_in` type (some
  servers return it as a string) but strict on `access_token` presence.
- 401 retry happens at the `AuthClient.fetchUser` layer, not the
  command layer — so future endpoints inherit it for free.
- `persistOAuth` merges into the existing store (preserves co-located
  `api_key`). `auth login` (API-key path) does the symmetric thing.

## Test plan

- [x] 80 unit tests, all green. `vitest run src/auth/`.
- [x] PKCE: verifier within 43-128 chars, challenge = SHA-256, S256
      method, distinct outputs each call.
- [x] Loopback: state mismatch / IdP error / missing-code / timeout /
      404 non-callback paths all rejected; success path captures `code`.
- [x] OAuth: `refreshTokens` posts correct body, persists, throws
      `REFRESH_FAILED` on 400/401 and `API_ERROR` on 5xx. Existing
      api_key preserved on refresh.
- [x] AuthClient: 401 retries with refreshed bearer on OAuth, does
      NOT retry for api_key, returns 401 if refresh hook fails.
- [x] `bunx oxlint` / `bunx oxfmt --check` / `bunx tsc` clean.
- [x] `bunx fallow audit --base origin/main --fail-on-issues` — only
      inherited `help.ts:showUsage` finding (from main, not this PR).
- [ ] Smoke test against dev API:
      `HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login`
      then `hyperframes auth status` then `hyperframes auth refresh`.

## Out of scope

- Cloud render commands — separate plan.
- PR 4 (heygen-cli read-side JSON support) — independent, ships after.
2026-05-28 02:13:24 -04:00
James 81aff68397 fix(cli): address code-review findings on OAuth PR 2026-05-28 05:59:24 +00:00
James Russo b9dbafdf6a feat(cli): add hyperframes auth login --api-key, status, logout (#1081)
## What

Introduces the `hyperframes auth` command group + a shared credential
store library that hyperframes-CLI and heygen-cli will both read from.

- `hyperframes auth login --api-key` saves a HeyGen API key to
  `~/.heygen/credentials.json` (stdin pipe or hidden-input prompt).
- `hyperframes auth status` resolves the active credential (env vars
  → file) and verifies it against `GET /v3/users/me`, printing
  identity + billing.
- `hyperframes auth logout` removes the credential (`--keep-api-key`
  drops only the OAuth block).

Internals (`packages/cli/src/auth/`):
- `paths.ts` — `~/.heygen` layout, `HEYGEN_CONFIG_DIR` override.
- `store.ts` — read/write `credentials.json` (file 0600, dir 0700)
  with legacy single-line plaintext fallback so existing heygen-cli
  users don't lose their session.
- `resolver.ts` — chain: `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` →
  file (unexpired OAuth wins over api_key).
- `client.ts` — hand-written typed wrapper for `GET /v3/users/me`
  (intentionally not OpenAPI codegen — single endpoint).
- `errors.ts` — typed `AuthError` with discriminating `code`.

## Why

This is the foundation for `hyperframes cloud render`. Splitting it
out keeps the cloud-render PR small and lets users sign in today.

The plan originally called for a library-only PR followed by a
commands PR. The `fallow` dead-code gate flagged the library-only
shape as unused exports, so I bundled them — the library and its
first consumers ship together. PR 3 (OAuth PKCE) and PR 4
(heygen-cli read-side JSON support) follow.

## How

- Credential file format: JSON with optional `api_key` + `oauth`
  blocks. Both CLIs read it; the resolver picks the freshest valid
  credential.
- Auth header selection happens in the HTTP client: OAuth →
  `Authorization: Bearer ...`, API key → `x-api-key: ...`.
- `HEYGEN_API_URL` lets dev testing target `api.dev.heygen.com`
  without rebuilding.
- The new `auth` command lazy-loads its subverbs (same pattern as
  `lambda`).

## Test plan

- [x] Unit tests added (`vitest`) for paths, store, resolver,
      client, and errors — 45 tests, all green.
- [x] `bunx tsc --noEmit -p packages/cli/tsconfig.json` clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` clean.
- [x] `bunx fallow audit --base origin/main --fail-on-issues` —
      zero new findings.
- [ ] Smoke test against dev API:
      `HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login --api-key`
      then `hyperframes auth status`.
2026-05-28 01:48:25 -04:00