@mevdragon/vidfarm-devcli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,19 +35,19 @@ npm run cdk:deploy:prod-serverless
35
35
 
36
36
  Never call bare `cdk deploy` — the npm scripts load the correct `.env.<stage>` and select the right stack.
37
37
 
38
- ## Local editor: `@mevdragon/vidfarm-devcli`
38
+ ## Local editor: `vidfarm serve`
39
39
 
40
- Pair a local composition directory with the hosted Trackpad Editor so an AI agent (Claude Code, Codex, etc.) or a human can edit `composition.html` on disk and see live preview in the browser.
40
+ Boot the **full** Vidfarm editor locally single origin, disk-backed records + storage — so an AI agent (Claude Code, Codex, etc.) edits composition files on disk while a human finishes in the browser, both sharing one source of truth with live sync. Render stays on the cloud.
41
41
 
42
42
  ```bash
43
- npx -y @mevdragon/vidfarm-devcli <template_id>
44
- # → downloads the current composition into .vidfarm/<template_id>/
45
- # → starts dev-serve on localhost:4321
46
- # → prints an https://vidfarm.cc/editor/<template_id>?fork=…&dev=… URL to open
43
+ npx -y @mevdragon/vidfarm-devcli serve
44
+ # → boots the full backend on http://localhost:3000 (records + storage on disk)
45
+ # → auto-provisions a local user and opens the editor, pre-authed
46
+ # → agents edit files under ./.vidfarm-local/storage the browser live-morphs on save
47
47
  ```
48
48
 
49
- Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or `VIDFARM_API_KEY`), `--share`, `--refetch`. Use `dev-serve` as an explicit subcommand to skip the fetch step and just serve an existing directory.
49
+ Everything runs locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`); no AWS is touched except **render**, which submits to the deployed cloud renderer when `VIDFARM_JOB_STATE_MACHINE_ARN` + `HYPERFRAMES_RENDER_*` are set (copy them from `.env.staging`). Multiple forks can be edited at once.
50
50
 
51
- Beyond the local editor loop, the devcli also wraps the **entire director REST flow** as 1:1 commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render`, `approve`, `schedule`, `posts`, `upload`/`download`, plus a raw `vidfarm api <METHOD> <path>` passthrough. Each maps to exactly one REST route and prints the prod frontend URL to open. Run `vidfarm --help` for the full surface, or see `SKILL.director.md` § "`vidfarm-devcli` — full command surface".
51
+ Flags: `--port` (default 3000), `--dir` (default `./.vidfarm-local`), `--key` (bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork <id>`, `--no-open`. `vidfarm <template_id>` is an alias for `serve <template_id>`.
52
52
 
53
- See `SKILL.platform.md` § "Local editor dev loop" for the request-rewrite / SSE reload flow.
53
+ Beyond the local editor, the devcli also wraps the **entire director REST flow** as 1:1 commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render`, `approve`, `schedule`, `posts`, `upload`/`download`, plus a raw `vidfarm api <METHOD> <path>` passthrough. Each maps to exactly one REST route and prints the prod frontend URL to open. Run `vidfarm --help` for the full surface, or see `SKILL.director.md` § "`vidfarm-devcli` full command surface".
package/SKILL.director.md CHANGED
@@ -166,7 +166,7 @@ Response:
166
166
 
167
167
  If `ghostcut_pending: true`, poll `POST /api/v1/compositions/:forkId/ghostcut-poll` until status transitions to `done` or `failed`.
168
168
 
169
- Read-only state (does **not** advance the job or bill): `GET /api/v1/compositions/:forkId/ghostcut`. Returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }`. Use this when you want to know both video URLs without triggering another poll — the AI copilot uses it to pick between the raw upload and the caption-free mirror before feeding a video into a primitive route (image extract, dedupe, trim, ai video edit, etc.). Prefer `mirrored_url` when `status === "done"` and the downstream call should be caption-free; prefer `original_source_url` when the user explicitly wants the raw upload or when `status !== "done"`.
169
+ Read-only state (does **not** advance the job or bill): `GET /api/v1/compositions/:forkId/ghostcut`. Returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }`. This is how you read the composition's **two video sources**: `original_source_url` is the **original video** (raw upload, captions intact) and `mirrored_url` is the **decomposed video** (the processed, caption-free copy the timeline renders from). Use this when you want both URLs without triggering another poll — the AI copilot uses it to pick between the original video and the decomposed video before feeding a video into a primitive route (image extract, dedupe, trim, ai video edit, etc.). Prefer the **decomposed video** (`mirrored_url`) when `status === "done"` and the downstream call should be caption-free; prefer the **original video** (`original_source_url`) when the user explicitly wants the raw upload or when `status !== "done"`.
170
170
 
171
171
  ## Video context (transcript + scene descriptions)
172
172
 
@@ -201,7 +201,7 @@ Read-only state (does **not** advance the job or bill): `GET /api/v1/composition
201
201
  Use it whenever you need to know what the video says or shows: writing/translating captions, matching hooks or dubs to spoken audio, or planning scene-level edits.
202
202
 
203
203
  - **Editor chat (frontend AI)** exposes this as the optional `video_context` tool — the copilot calls it on demand.
204
- - **Desktop agents (Claude Code / Codex)**: `vidfarm edit <template_id>` syncs the same payload to `video-context.json` on disk next to `composition.html` (use `--refetch` to refresh it after a new decompose), or fetch the route directly.
204
+ - **Desktop agents (Claude Code / Codex)**: fetch the `video-context.json` route directly (e.g. `vidfarm api GET /api/v1/compositions/<forkId>/video-context.json`) to ground edits in what the video says and shows.
205
205
 
206
206
  ## Render (publish to MP4)
207
207
 
@@ -381,39 +381,29 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
381
381
 
382
382
  **Uploads/downloads live in the devcli** because they are genuine multi-step / streaming flows: `upload` posts the file as multipart to the temporary-files route and prints the durable URL to drop into a composition or approved post; `download` streams any Vidfarm/media URL to disk.
383
383
 
384
- ## Local dev loop (edit on disk)
384
+ ## Local dev loop (`vidfarm serve`)
385
385
 
386
- For iterating on a composition on disk (e.g. Claude Code editing HTML directly), pair the hosted editor with a local composition directory:
386
+ Run the **full** editor locally so a coding agent (Claude Code / Codex) edits composition files on disk while a human finishes in the browser one source of truth, live sync both ways.
387
387
 
388
388
  ```bash
389
- npx -y @mevdragon/vidfarm-devcli <template_id>
390
- # → downloads composition.html/json + manifest.json + video-context.json into .vidfarm/<template_id>/
391
- # → downloads the videos + per-scene frames for local analysis (see below)
392
- # → starts dev-serve on http://localhost:4321
393
- # → prints https://vidfarm.cc/editor/<template_id>?fork=<forkId>&dev=http%3A%2F%2Flocalhost%3A4321
389
+ npx -y @mevdragon/vidfarm-devcli serve <template_id>
390
+ # → boots the full backend on http://localhost:3000 (records + storage on disk)
391
+ # → pulls that template's default fork (or --fork <id>) from the cloud onto disk
392
+ # → auto-provisions a local user and opens the editor, pre-authed
394
393
  ```
395
394
 
396
- ### Local media for analysis (`sources.json`, videos, frames)
395
+ Everything runs locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`) — no AWS touched **except render**, which submits to the deployed cloud renderer when `VIDFARM_JOB_STATE_MACHINE_ARN` + `HYPERFRAMES_RENDER_*` are set (copy them from `.env.staging`); otherwise the editor's render button returns a clear `render_unavailable` message. Media referenced by the composition stays on its cloud URLs.
397
396
 
398
- So a coding agent can *see* what it is editing — not just read URLs — `vidfarm edit` also drops the actual media next to the composition:
397
+ How the loop works:
398
+ - The composition lives on disk at `<data-dir>/storage/compositions/forks/<forkId>/working/composition.html` (default `<data-dir>` is `./.vidfarm-local`). Point your agent at that file.
399
+ - When the agent saves it, the server's `fs.watch` fans a `reload` for that fork over **same-origin** SSE (`GET /api/v1/dev/events`) and the open editor tab live-morphs the change — no reload, playback state preserved (keyed DOM morph on `data-hf-id`).
400
+ - When the human edits in the browser, the editor `PUT`s the composition back to the same file; the server suppresses that self-write so it doesn't echo. Multiple forks can be edited at once under one server.
399
401
 
400
- - **`original.mp4`** the raw source video, before any processing.
401
- - **`source.mp4`** — the video the composition timeline actually renders from. This differs from `original.mp4` only when the fork was ghostcut/subtitle-removed; otherwise `sources.json` marks them `same_as_decomposed: true` and only `source.mp4` is written.
402
- - **`frames/<scene>.png`** — one still per timeline scene, sampled at the scene midpoint from `source.mp4`. Open these as images to analyze what each scene shows. Requires `ffmpeg` (bundled via `ffmpeg-static`); if unavailable the videos still download and frames are skipped.
403
- - **`sources.json`** — the manifest tying it together: each video's role/url/local path/byte size, the total duration, and per-scene `{ slug, start, duration, label, description, transcript_excerpt, frame }`.
402
+ Seeding from the cloud: `serve <template_id>` pulls the template's **default fork** (which may be another user's public decomposition); `serve --fork <id>` pulls a specific fork. Only the composition + records are localized — media stays on cloud URLs. Re-serving keeps local edits unless you pass `--refetch`.
404
403
 
405
- This is best-effort and never blocks the editor session. Re-run with `--refetch` to refresh the media after a new decompose or ghostcut. For deeper text grounding (full transcript, per-scene visual descriptions) read `video-context.json` the media files complement it with the pixels.
404
+ Flags: `--port` (default 3000), `--dir` (default `./.vidfarm-local`), `--key` (bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork <id>`, `--host` (cloud host to pull from, default `https://vidfarm.cc`), `--api-key` (cloud key for the pull), `--refetch`, `--no-open`. `vidfarm <template_id>` is an alias for `serve <template_id>`.
406
405
 
407
- Open the printed URL in your browser. An orange **LOCAL DEV MODE** banner pins to the top with an Exit button. All fork API fetches now route to your local dir instead of the deployed API; disk edits push an SSE reload event and the editor re-fetches automatically.
408
-
409
- Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or set `VIDFARM_API_KEY`), `--share`, `--refetch`. For a fork that already has composition files on disk (or that you've populated manually), use `vidfarm-devcli dev-serve --dir ./my-fork --port 4321` to skip the fetch step.
410
-
411
- The dev source is persisted in `localStorage.VIDFARM_DEV_SOURCE`, so you don't need to keep the `?dev=` param in the URL after the first load.
412
-
413
- `dev-serve` (`vidfarm-devcli` 0.3.2+) is realtime-safe:
414
- - Writes coming through `PUT /composition.html` / `PATCH /composition.json` **do not loop back** as reload events — the server suppresses filesystem notifications for the just-written file for 500ms so the editor's own save never triggers a self-reload.
415
- - The SSE `hello` event carries an `instance` id + a monotonic `cursor`. If the client reconnects after a server restart the browser detects the new instance and fires a `vidfarm:dev-reload` so the editor resyncs from disk. A 20s heartbeat keeps the SSE connection alive through intermediate proxies.
416
- - Port collisions surface as a clear `[dev-serve] port <n> is already in use` message instead of a raw stack trace. Request bodies are capped at 8MB and time out after 15s so a stuck client can't hang the server. File paths are canonicalised so `../etc/passwd`-style paths cannot escape `--dir`.
406
+ To *analyze* the source media locally (videos, transcript, recurring cast), read the `video-context.json` / `cast.json` routes, or pull a media URL with `vidfarm download <url>`.
417
407
 
418
408
  ## What NOT to do
419
409
 
@@ -553,7 +543,7 @@ Analyze a source video and identify where a product can be organically placed so
553
543
 
554
544
  - `POST /api/v1/primitives/brainstorm/product_placement`
555
545
  - Body: `{ "tracer": "...", "payload": { "source_video_url": "...", "offer_description": "...", "count"?: 8, "provider"?: "gemini" }, "webhook_url"?: "..." }`
556
- - `source_video_url` (required, URL) — the exact durable/public video to analyze. Aliases accepted: `video_url`, `source_url`, `url`. For a fork's composition, call `GET /api/v1/compositions/:forkId/ghostcut` first and pass the correct raw-upload vs caption-free-mirror URL.
546
+ - `source_video_url` (required, URL) — the exact durable/public video to analyze. Aliases accepted: `video_url`, `source_url`, `url`. For a fork's composition, call `GET /api/v1/compositions/:forkId/ghostcut` first and pass the correct URL the **original video** (`original_source_url`) or the **decomposed video** (`mirrored_url`).
557
547
  - `offer_description` (required) — the product/offer to place. Aliases: `offer`, `description`, `details`.
558
548
  - `count` (optional, 3–30, default 8) — number of opportunities; only send when the user asks for a specific number.
559
549
  - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` to completion, then read `result.json` (also surfaced on the job output as `opportunities[]`), where each entry has `timestamp`, `scene`, `placement_type`, `placement_idea`, and `why_it_works`.
package/SKILL.platform.md CHANGED
@@ -209,7 +209,7 @@ Both modes update the composition.html and composition.json in place, and create
209
209
 
210
210
  1. **Editor chat** — the optional `video_context` tool (registered alongside `http_request` in both `src/app.ts` and `infra/lambda/editor-chat.ts`) fetches it on demand; the shared system prompt in `src/editor-chat.ts` tells the model when to reach for it (what does the video say/show, caption/dub matching, scene-level edit planning).
211
211
  2. **`http_request`** — the route lives under `COMPOSITIONS_PREFIX`, so chat can also GET it directly (e.g. for a different fork).
212
- 3. **Desktop agents (Claude Code / Codex)** — the devcli (`src/cli.ts`) syncs it to `video-context.json` on disk next to `composition.html` during `vidfarm edit`; `--refetch` refreshes it after a new decompose.
212
+ 3. **Desktop agents (Claude Code / Codex)** — fetch the `video-context.json` route directly (e.g. `vidfarm api GET /api/v1/compositions/<forkId>/video-context.json`) to ground on-disk edits in what the video says and shows.
213
213
 
214
214
  **Source-duration guardrail.** `probeMediaAsset()` in `src/services/media-processing.ts` enforces a hard cap of `MAX_PROBE_DURATION_SECONDS` (120s). If the probed source exceeds it, the probe throws `MediaDurationExceededError` and the route responds with `400 { code: "source_too_long", duration_seconds, max_duration_seconds }`. The same cap applies to inspiration ingest (`ensureInspirationReadyThenFork`) — over-long submissions land as `InspirationRecord { status: "failed", errorMessage }` without ever creating a Template or fork. This is baked into the probe (not the route) so every consumer — auto-decompose, ingest, `video_probe` / `audio_probe` primitives — is protected from repeated probes of long videos being used to burn Lambda / ffmpeg / vision-API budget.
215
215
 
@@ -351,39 +351,36 @@ Legacy but still active:
351
351
  - Storage keys + public-read prefix list — `src/services/storage.ts`
352
352
  - Fork manifest.json helper — `src/services/fork-manifest.ts`
353
353
  - Composition runtime script — `src/composition-runtime.ts`
354
- - Trackpad Editor — `demo/src/HyperframesStudioEditor.tsx`
355
- - Editor local-mode bootstrap — `demo/src/dev-mode.ts`
356
- - Devcli (`vidfarm-devcli`) — `src/cli.ts` (edit-mode fetch + banner, the REST-wrapper commands, and the shared `apiRequest()` helper), `src/dev-serve.ts` (raw HTTP shim)
354
+ - Trackpad Editor — `demo/src/HyperframesStudioEditor.tsx` (the same-origin live-reload `EventSource` on `/api/v1/dev/events` lives here)
355
+ - Local records/auth disk shim — `src/services/local-dynamo.ts` (`RECORDS_DRIVER=local`)
356
+ - Local live-reload watcher — `src/services/composition-watch.ts` (`fs.watch` → SSE, `STORAGE_DRIVER=local`)
357
+ - Devcli (`vidfarm-devcli`) — `src/cli.ts` (`serve` = boot the full app locally + cloud-seed, the REST-wrapper commands, and the shared request helper)
357
358
  - Auto-decompose logic — `src/hyperframes/composition.ts` (time-slice), `src/services/hyperframes.ts` (smart)
358
359
  - Publish flow — `src/app.ts` `publishCompositionToRenderer()`
359
360
  - Ghostcut — `src/services/ghostcut.ts`
360
361
  - CDK stack — `infra/cdk/lib/vidfarm-serverless-staging-stack.ts`, `infra/cdk/lib/vidfarm-prod-stack.ts`
361
362
 
362
- ## Local editor dev loop
363
+ ## Local editor dev loop (`vidfarm serve`)
363
364
 
364
- `@mevdragon/vidfarm-devcli` (`src/cli.ts` + `src/dev-serve.ts`) has two roles. First, it pairs a local directory of composition files with the hosted Trackpad Editor so Claude Code, Codex, or a human can edit `composition.html` on disk and see instant preview reload in the browser (the `edit`/`publish`/`dev-serve` commands, detailed below). Second, it wraps the **entire director REST flow** as 1:1 CLI commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render` (→ `/export`), `approve`, `schedule`, `posts`/`schedules`, `upload`/`download`, `login`/`whoami`/`provider-keys`, plus a raw `api <METHOD> <path>` passthrough. Every wrapper routes through a single `apiRequest()` helper (auth + host + JSON), so it never duplicates route logic; the command↔route table and the raw-REST-vs-devcli guidance live in `SKILL.director.md`. Each command prints its prod frontend URL (editor / discover / approved-post `share_url`) as a first-class output, and `--json` gives pure JSON for agents.
365
+ `@mevdragon/vidfarm-devcli` (`src/cli.ts`) has two roles. First, `serve` boots the **full** Vidfarm backend locally single origin, disk-backed records + storage — so Claude Code, Codex, or a human can edit `composition.html` on disk and see the browser live-morph the change (detailed below). Second, it wraps the **entire director REST flow** as 1:1 CLI commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render` (→ `/export`), `approve`, `schedule`, `posts`/`schedules`, `upload`/`download`, `login`/`whoami`/`provider-keys`, plus a raw `api <METHOD> <path>` passthrough. Each command prints its prod frontend URL as a first-class output, and `--json` gives pure JSON for agents. The command↔route table lives in `SKILL.director.md`.
365
366
 
366
367
  ```bash
367
- # One-shot: fetch composition, start dev-serve, print editor URL.
368
- vidfarm-devcli <template_id>
369
- # → downloads composition.html/json + manifest.json into .vidfarm/<template_id>/
370
- # → listens on http://localhost:4321
371
- # → prints https://vidfarm.cc/editor/<template_id>?fork=<forkId>&dev=http%3A%2F%2Flocalhost%3A4321
372
-
373
- # Advanced: skip fetch, serve an existing directory.
374
- vidfarm-devcli dev-serve --dir ./my-fork --port 4321
375
- # Then open the editor with ?dev=1 (or ?dev=host:port / ?dev=<full-url>)
376
- ```
368
+ # Boot the full editor locally, pull a template's default fork onto disk, open pre-authed.
369
+ vidfarm-devcli serve <template_id>
370
+ # → http://localhost:3000, records + storage under ./.vidfarm-local
371
+ # → composition at ./.vidfarm-local/storage/compositions/forks/<forkId>/working/composition.html
377
372
 
378
- Edit flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or `VIDFARM_API_KEY`), `--share`, `--refetch`.
373
+ # Pull a specific cloud fork instead of the template default:
374
+ vidfarm-devcli serve --fork <forkId> --host https://vidfarm.cc --api-key <key>
375
+ ```
379
376
 
380
- `dev-serve` behavior (`vidfarm-devcli` 0.3.2+):
381
- - Serves `composition.html` / `composition.json` / `manifest.json` (and `/versions/{n}/…`) with CORS `*`.
382
- - Accepts `PUT /composition.html` and `PATCH /composition.json`.
383
- - Watches the dir with `fs.watch`, coalesces bursts (80ms), fans out reload events via `GET /_events` SSE.
384
- - Reload loop prevention: any successful `PUT` / `PATCH` marks the target file "self-write suppressed" for 500ms so the fs.watch event fired by the write we just performed does NOT round-trip back to the editor as a reload event.
385
- - SSE `hello` includes `{ at, instance, cursor }`. `instance` changes on every dev-serve restart so a reconnecting client can detect a restart and force a resync; `cursor` is a monotonic counter that bumps on every broadcast reload. Heartbeat `: heartbeat <ts>` every 20s keeps intermediate proxies from timing out the connection.
386
- - Safety rails: request bodies capped at 8MB with a 15s body-read timeout; path resolution runs through `path.relative(rootDir, ...)` so `versions/../../etc/passwd`-style paths cannot escape the served directory; `EADDRINUSE` produces a plain-English error instead of a raw stack; broadcast iterates a snapshot of the client set so a mid-broadcast disconnect can't skip healthy clients; `SIGINT` + `SIGTERM` both close cleanly with a 2s hard-exit fallback.
377
+ Flags: `--port` (3000), `--dir` (`./.vidfarm-local`), `--key` (local bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork`, `--host`, `--api-key` (cloud pull key), `--refetch`, `--no-open`. `vidfarm <template_id>` aliases `serve <template_id>`.
387
378
 
388
- The editor side lives in `demo/src/dev-mode.ts` — a self-installing module imported at the top of `demo/src/main.tsx`. It reads `?dev=<url>` (persisted to `localStorage.VIDFARM_DEV_SOURCE`), monkey-patches `window.fetch` to rewrite `/api/v1/compositions/{forkId}/*` → `${devSource}/*`, injects a persistent orange banner with an Exit button, and dispatches `vidfarm:dev-reload` on SSE reload events. It also caches the `hello.instance` and re-dispatches `vidfarm:dev-reload` with `{ reason: "server-restart" }` when a reconnect sees a different instance. `HyperframesStudioEditor.tsx` listens for that event and re-runs the composition fetch effect with `cache: "reload"`.
379
+ **Architecture** (fully local; render is the only cloud dependency):
380
+ - **Records/auth** — `src/services/local-dynamo.ts` is a drop-in `.send()` shim for the DynamoDB DocumentClient used by `serverless-records.ts` + `serverless-auth.ts`, servicing Get/Put/Delete/Query/Scan/Update against JSON files under `<data-dir>/dynamo/<table>/`. Selected by `config.RECORDS_DRIVER` (`z.enum(["local","dynamo"]).default("dynamo")` — deployed envs are unaffected). The 25 call sites are unchanged; only the client construction switches.
381
+ - **Storage** — `StorageService` already writes to disk when `STORAGE_DRIVER=local` / `AWS_S3_BUCKET=""` (files under `<data-dir>/storage/`). Composition public URLs become `${PUBLIC_BASE_URL}/storage/<key>`.
382
+ - **Auth** — no seeding: `VIDFARM_API_KEY` + the existing `tryBootstrapFromEnv` auto-provisions a paid customer; `serve` opens `/auto-login?api_key=…&redirect=/editor/…` to plant the session cookie so the browser lands pre-authed.
383
+ - **Live push** — `src/services/composition-watch.ts` (`fs.watch` on the forks dir, only when `STORAGE_DRIVER=local`) coalesces bursts (80ms) and broadcasts `{ forkId, file }` to `GET /api/v1/dev/events` (Hono `streamSSE`, cookie-authed). The editor opens an `EventSource` there gated on the boot flag `liveReload`, and on a `reload` for its fork refetches `composition.html` and runs `applyExternalCompositionHtml` → `morphLiveCompositionDom` (keyed on `data-hf-id`; media buffers + playback survive). The composition write routes call `compositionWatch.suppressFork(forkId)` so the editor's own `PUT` doesn't echo.
384
+ - **Cloud-seed** — `serve` pulls the composition for `--fork` (or the template's default fork via `resolveForkId`, which follows the cloud `/editor` redirect and grabs even another user's public decomposition), then materializes a local template + fork record and writes the composition to the working key so `/editor/:templateId?fork=:id` resolves offline. Media stays on cloud URLs.
385
+ - **Render** — cloud only (no local job runner; hyperframes render is a Lambda fan-out). `publishCompositionToRenderer` returns `render_unavailable` (501) when `VIDFARM_JOB_STATE_MACHINE_ARN` is unset; wire the `HYPERFRAMES_RENDER_*` + state-machine env from `.env.staging` to submit to the deployed renderer.
389
386
  - Lambdas — `infra/lambda/*.ts`
package/dist/src/cli.js CHANGED
@@ -1,16 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { parseArgs } from "node:util";
5
7
  import { spawnSync } from "node:child_process";
6
8
  import { Readable } from "node:stream";
7
9
  import { pipeline } from "node:stream/promises";
8
- // vidfarm-devcli — thin CLI that pairs a local composition directory with the
9
- // hosted Trackpad Editor at https://vidfarm.cc/editor/. Point it at a template
10
- // id, and it: resolves the user's fork, downloads the current composition,
11
- // starts a local dev-serve, and prints the editor URL to open in the browser.
10
+ // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
11
+ // `serve` command boots the FULL editor locally (single origin, disk-backed
12
+ // records + storage) so power users edit compositions on disk while a browser
13
+ // editor and a coding agent share one source of truth; render stays on the
14
+ // cloud. The remaining commands are thin 1:1 wrappers over the REST API.
12
15
  const DEFAULT_HOST = "https://vidfarm.cc";
13
16
  const DEFAULT_PORT = 4321;
17
+ // Agent skill files the `update-skill` command can install from the live host
18
+ // (`GET /skill/<name>`), falling back to the copy bundled in this npm package.
19
+ const SKILL_TARGETS = {
20
+ "vidfarm-director": { route: "/skill/vidfarm-director", bundled: "SKILL.director.md" },
21
+ "vidfarm-platform": { route: "/skill/vidfarm-platform", bundled: "SKILL.platform.md" }
22
+ };
14
23
  // Every command below is a thin wrapper over ONE Vidfarm REST call (the `→`
15
24
  // line documents the exact route). The devcli adds nothing the raw API can't
16
25
  // do — it just resolves your fork, sets the `vidfarm-api-key` header, prints
@@ -30,9 +39,21 @@ Usage:
30
39
  vidfarm --help
31
40
 
32
41
  Local editor loop:
33
- <template_id> [opts] Fetch composition + media, dev-serve, print editor URL
42
+ serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
43
+ Records + storage run on disk; render stays on cloud.
44
+ With a template_id or --fork, pulls that composition
45
+ (or the template's default/shared decomposition) from
46
+ the cloud host onto local disk to edit offline.
47
+ --port <n> Server port (default: 3000)
48
+ --dir <path> Local data dir (default: ./.vidfarm-local)
49
+ --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
50
+ --fork <id> Pull + open a specific cloud fork
51
+ --host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
52
+ --api-key <key> Cloud key for the pull (default: VIDFARM_API_KEY)
53
+ --refetch Overwrite local composition with the cloud copy
54
+ --no-open Don't auto-open the browser
55
+ <template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
34
56
  publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
35
- dev-serve Serve an existing local dir only (no fetch)
36
57
 
37
58
  Discover & inspiration (browse the viral-video catalog, add your own source):
38
59
  discover List the template/inspiration feed → GET /discover/feed
@@ -43,7 +64,8 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
43
64
  Composition lifecycle (fork → decompose → snapshot → render):
44
65
  fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
45
66
  decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
46
- ghostcut <forkId> Read subtitle-removal status (non-billing) → GET .../compositions/:forkId/ghostcut
67
+ ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
68
+ decomposed (caption-free), non-billing
47
69
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
48
70
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
49
71
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/export
@@ -66,9 +88,21 @@ Account:
66
88
  provider-keys List saved AI provider keys → GET /api/v1/user/me/provider-keys
67
89
  add-provider-key <provider> <secret> Save an AI provider key → POST /api/v1/user/me/provider-keys
68
90
 
91
+ Agent skill (install the latest director skill so your AI agent can act):
92
+ update-skill Fetch latest SKILL.director.md → .claude/skills → GET /skill/vidfarm-director
93
+ --global Install into ~/.claude/skills (default: ./.claude/skills)
94
+ --dir <path> Install into a custom skills root
95
+ --platform Also install SKILL.platform.md (vidfarm-platform)
96
+ --print Print the skill to stdout instead of writing a file
97
+
69
98
  Files (multi-step flows the devcli handles for you):
70
99
  upload <file> Upload a local file, print its durable URL → POST /api/v1/user/me/temporary-files/upload
100
+ --folder <path> Namescope the upload under a product/offer folder
71
101
  download <url> [dest] Stream any Vidfarm/media URL to disk
102
+ files List My Files assets + folders → GET /api/v1/user/me/attachments
103
+ --folder <path> Only files under this folder (e.g. a product/offer)
104
+ get-file <id> [dest] Resolve a My Files id to its URL and download it
105
+ --print Print text contents (md/txt/csv/json) instead of saving
72
106
 
73
107
  Escape hatch — call ANY route directly:
74
108
  api <METHOD> <path> Raw REST call with auth + pretty errors
@@ -84,11 +118,12 @@ Common options (any command):
84
118
  --share <token> Share token for a shared fork (public read)
85
119
  --json Print only the raw JSON response (agent-friendly)
86
120
 
87
- Editor-mode options:
88
- --port <n> Local dev-serve port (default: ${DEFAULT_PORT})
89
- --dir <path> Local composition directory (default: .vidfarm/<template_id>)
90
- --fork <id> Fork id (default: auto-resolve for the current user)
91
- --refetch Overwrite local composition files with the latest remote copy
121
+ Serve-mode options:
122
+ --port <n> Local server port (default: 3000)
123
+ --dir <path> Local data dir for disk records + storage (default: ./.vidfarm-local)
124
+ --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
125
+ --fork <id> Open a specific fork (multiple forks editable at once)
126
+ --no-open Don't auto-open the browser
92
127
 
93
128
  Publish-mode options:
94
129
  --message <text> Message attached to the published version snapshot
@@ -107,13 +142,11 @@ async function main() {
107
142
  const command = argv[0];
108
143
  const rest = argv.slice(1);
109
144
  switch (command) {
110
- case "dev-serve": {
111
- const { runDevServeCommand } = await import("./dev-serve.js");
112
- await runDevServeCommand(rest);
113
- return;
114
- }
145
+ // `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
146
+ // editor locally. The old cross-origin dev-serve/`?dev=` flow is gone.
147
+ case "serve":
115
148
  case "edit":
116
- await runEditCommand(rest);
149
+ await runServeCommand(rest);
117
150
  return;
118
151
  case "publish":
119
152
  await runPublishCommand(rest);
@@ -188,6 +221,10 @@ async function main() {
188
221
  case "whoami":
189
222
  await runWhoamiCommand(rest);
190
223
  return;
224
+ case "update-skill":
225
+ case "skill":
226
+ await runUpdateSkillCommand(rest);
227
+ return;
191
228
  case "provider-keys":
192
229
  await runProviderKeysCommand(rest);
193
230
  return;
@@ -200,10 +237,16 @@ async function main() {
200
237
  case "download":
201
238
  await runDownloadCommand(rest);
202
239
  return;
240
+ case "files":
241
+ await runFilesCommand(rest);
242
+ return;
243
+ case "get-file":
244
+ await runGetFileCommand(rest);
245
+ return;
203
246
  default:
204
247
  if (!command.startsWith("-")) {
205
- // Positional template id → default `edit` command.
206
- await runEditCommand(argv);
248
+ // Positional template id → default to booting the local editor.
249
+ await runServeCommand(argv);
207
250
  return;
208
251
  }
209
252
  throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
@@ -318,61 +361,205 @@ function collectKeyValues(entries) {
318
361
  }
319
362
  return out;
320
363
  }
321
- async function runEditCommand(argv) {
364
+ // `vidfarm serve` — boot the FULL Vidfarm backend locally (single origin), on
365
+ // disk-backed records + storage, so power users edit compositions locally while
366
+ // a browser editor and a coding agent share one source of truth. Render stays
367
+ // on the cloud (see Phase 4). This replaces the cross-origin `?dev=` shim.
368
+ async function runServeCommand(argv) {
322
369
  const parsed = parseArgs({
323
370
  args: argv,
324
371
  allowPositionals: true,
325
372
  options: {
326
- port: { type: "string", default: String(DEFAULT_PORT) },
373
+ port: { type: "string", default: "3000" },
327
374
  dir: { type: "string" },
328
- host: { type: "string", default: DEFAULT_HOST },
375
+ key: { type: "string" },
329
376
  fork: { type: "string" },
377
+ host: { type: "string", default: DEFAULT_HOST },
330
378
  "api-key": { type: "string" },
331
379
  share: { type: "string" },
332
- refetch: { type: "boolean", default: false }
380
+ refetch: { type: "boolean", default: false },
381
+ open: { type: "boolean", default: true },
382
+ "no-open": { type: "boolean", default: false }
333
383
  }
334
384
  });
335
- const templateId = parsed.positionals[0];
336
- if (!templateId) {
337
- throw new Error(`edit requires a template id.\n\n${HELP}`);
338
- }
339
385
  const port = Number(parsed.values.port);
340
386
  if (!Number.isFinite(port) || port <= 0) {
341
387
  throw new Error(`Invalid --port: ${parsed.values.port}`);
342
388
  }
389
+ const dataDir = path.resolve(process.cwd(), parsed.values.dir ?? ".vidfarm-local");
390
+ mkdirSync(dataDir, { recursive: true });
391
+ // Capture the ambient cloud key BEFORE we overwrite VIDFARM_API_KEY with the
392
+ // local bootstrap key, so `--fork`/template seeding can still authenticate to
393
+ // the cloud host.
394
+ const envKey = process.env.VIDFARM_API_KEY;
395
+ // A stable local bootstrap key: first --key, else VIDFARM_API_KEY, else a
396
+ // fixed dev key. The same value auto-provisions the paid customer (via
397
+ // tryBootstrapFromEnv) and pre-auths the browser through /auto-login.
398
+ const apiKey = parsed.values.key ?? envKey ?? "vidfarm-local-dev-key";
399
+ const cloudApiKey = parsed.values["api-key"] ?? envKey;
343
400
  const host = trimTrailingSlash(parsed.values.host);
344
- const apiKey = parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY;
345
- const shareToken = parsed.values.share;
346
- const rootDir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", templateId));
347
- mkdirSync(rootDir, { recursive: true });
348
- const forkId = parsed.values.fork ?? await resolveForkId({ host, templateId, apiKey, shareToken });
349
- if (!forkId) {
350
- throw new Error(`Could not resolve a fork id for template ${templateId}. Pass --fork <id>, or open ${host}/editor/${templateId} once so a fork is created.`);
351
- }
352
- await fetchCompositionFiles({
353
- host,
354
- forkId,
355
- dir: rootDir,
356
- apiKey,
357
- shareToken,
358
- refetch: parsed.values.refetch
359
- });
360
- // Give local coding agents (Claude Code / Codex / opencode) the actual media
361
- // to look at: the original source video, the composition's decomposed source
362
- // (which may differ after ghostcut subtitle-removal), one still frame per
363
- // scene, and a sources.json manifest tying it all together. Best-effort —
364
- // never blocks the editor session if a download or frame extraction fails.
401
+ const templateId = parsed.positionals[0];
402
+ const forkFlag = parsed.values.fork;
403
+ // Env MUST be set before importing runtime — config.ts reads it at module
404
+ // load. Force the fully-local drivers; keep any cloud creds already present
405
+ // in the environment so render passthrough still works.
406
+ process.env.RECORDS_DRIVER = "local";
407
+ process.env.STORAGE_DRIVER = "local";
408
+ process.env.AWS_S3_BUCKET = "";
409
+ process.env.VIDFARM_DATA_DIR = dataDir;
410
+ process.env.VIDFARM_API_KEY = apiKey;
411
+ process.env.PORT = String(port);
412
+ if (!process.env.NODE_ENV)
413
+ process.env.NODE_ENV = "development";
414
+ const { startRuntime } = await import("./runtime.js");
415
+ await startRuntime();
416
+ // If a template/fork was requested, pull its composition from the cloud host
417
+ // onto local disk and materialize the matching local records so the editor
418
+ // resolves it. Best-effort: a failure just drops us to /discover.
419
+ let openTemplateId = templateId;
420
+ let openForkId = forkFlag;
421
+ if (templateId || forkFlag) {
422
+ try {
423
+ const seeded = await seedForkFromCloud({
424
+ host,
425
+ templateId,
426
+ forkId: forkFlag,
427
+ cloudApiKey,
428
+ shareToken: parsed.values.share,
429
+ bootstrapKey: apiKey,
430
+ refetch: parsed.values.refetch
431
+ });
432
+ if (seeded) {
433
+ openTemplateId = seeded.templateId;
434
+ openForkId = seeded.forkId;
435
+ }
436
+ }
437
+ catch (error) {
438
+ console.warn(`[vidfarm] serve: cloud seed failed (${error instanceof Error ? error.message : String(error)}); starting empty.`);
439
+ }
440
+ }
441
+ const base = `http://localhost:${port}`;
442
+ const editorPath = openTemplateId
443
+ ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
444
+ : "/discover";
445
+ const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
446
+ printServeBanner({ base, dataDir, openUrl, editorPath });
447
+ const shouldOpen = parsed.values.open && !parsed.values["no-open"];
448
+ if (shouldOpen)
449
+ openInBrowser(openUrl);
450
+ // startRuntime() keeps the process alive via its HTTP server; nothing else to do.
451
+ }
452
+ // Pull a cloud fork's composition onto local disk and create the local
453
+ // template + fork records so the editor resolves it. The fork is either an
454
+ // explicit --fork or the template's default fork (which may be another user's
455
+ // public decomposition). Media stays on cloud URLs — only the composition +
456
+ // records are localized. Runs in-process after startRuntime(), so it shares the
457
+ // server's disk-backed records/storage singletons.
458
+ async function seedForkFromCloud(input) {
459
+ const { serverlessRecords } = await import("./services/serverless-records.js");
460
+ const { StorageService } = await import("./services/storage.js");
461
+ const { ServerlessAuthService } = await import("./services/serverless-auth.js");
462
+ const storage = new StorageService();
463
+ const auth = new ServerlessAuthService();
464
+ // Ensure the local bootstrap customer exists and grab its id to own the seed.
465
+ const customer = await auth.authenticate(input.bootstrapKey);
466
+ const authHeaders = buildAuthHeaders({ apiKey: input.cloudApiKey, shareToken: input.shareToken });
467
+ // 1. Resolve the source fork: explicit --fork wins, else the template's
468
+ // default fork (grabs the shared decomposition even if another user owns it).
469
+ let cloudForkId = input.forkId ?? null;
470
+ if (!cloudForkId && input.templateId) {
471
+ cloudForkId = await resolveForkId({ host: input.host, templateId: input.templateId, apiKey: input.cloudApiKey, shareToken: input.shareToken });
472
+ }
473
+ if (!cloudForkId) {
474
+ console.warn("[vidfarm] serve: no --fork and no default fork resolvable from cloud; starting empty.");
475
+ return null;
476
+ }
477
+ // 2. Read the serialized fork for its template id + title.
478
+ const forkMetaRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}`, { headers: authHeaders });
479
+ const forkMeta = forkMetaRes.ok ? (await forkMetaRes.json().catch(() => null)) : null;
480
+ const templateId = input.templateId ?? (typeof forkMeta?.template_id === "string" ? forkMeta.template_id : null);
481
+ if (!templateId || !templateId.startsWith("template_")) {
482
+ console.warn(`[vidfarm] serve: could not determine a template id for fork ${cloudForkId}; starting empty.`);
483
+ return null;
484
+ }
485
+ const title = (typeof forkMeta?.title === "string" && forkMeta.title) || `Local · ${templateId}`;
486
+ // 3. Materialize the local template record (the editor 404s without it).
487
+ const existingTemplate = await serverlessRecords.getTemplate(templateId);
488
+ if (!existingTemplate) {
489
+ await serverlessRecords.createTemplate({
490
+ id: templateId,
491
+ inspirationId: `local:${templateId}`,
492
+ customerId: customer.id,
493
+ originalUrl: typeof forkMeta?.original_url === "string" ? forkMeta.original_url : "",
494
+ sourceHost: "local",
495
+ title,
496
+ visibility: "public"
497
+ });
498
+ }
499
+ // 4. Materialize the local fork (reuse the cloud id for stable URLs).
500
+ const localForkId = cloudForkId;
501
+ const existingFork = await serverlessRecords.getCompositionFork(localForkId);
502
+ if (!existingFork) {
503
+ await serverlessRecords.createCompositionFork({
504
+ id: localForkId,
505
+ customerId: customer.id,
506
+ templateId,
507
+ title,
508
+ visibility: "private"
509
+ });
510
+ }
511
+ // 5. Pull composition.html/json onto disk (skip if present unless --refetch,
512
+ // so local edits aren't clobbered on re-serve).
513
+ const htmlKey = storage.compositionForkWorkingKey(localForkId, "composition.html");
514
+ const jsonKey = storage.compositionForkWorkingKey(localForkId, "composition.json");
515
+ const hasLocalHtml = Boolean(await storage.readText(htmlKey));
516
+ if (!hasLocalHtml || input.refetch) {
517
+ const htmlRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.html`, { headers: authHeaders });
518
+ if (!htmlRes.ok) {
519
+ console.warn(`[vidfarm] serve: could not fetch composition.html for ${cloudForkId} (${htmlRes.status}); starting empty.`);
520
+ return null;
521
+ }
522
+ await storage.putText(htmlKey, await htmlRes.text(), "text/html; charset=utf-8");
523
+ const jsonRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.json`, { headers: authHeaders });
524
+ if (jsonRes.ok)
525
+ await storage.putBuffer(jsonKey, Buffer.from(await jsonRes.text(), "utf8"), "application/json");
526
+ console.log(`[vidfarm] seeded fork ${localForkId} from ${input.host}`);
527
+ }
528
+ else {
529
+ console.log(`[vidfarm] fork ${localForkId} already local (use --refetch to overwrite)`);
530
+ }
531
+ // 6. Point the template's default fork at the local copy so a bare
532
+ // /editor/:templateId resolves it too.
533
+ await serverlessRecords.stampDefaultForkIfUnset({ templateId, forkId: localForkId });
534
+ return { templateId, forkId: localForkId };
535
+ }
536
+ function printServeBanner(input) {
537
+ const line = `${DIM}${"─".repeat(74)}${RESET}`;
538
+ console.log("");
539
+ console.log(line);
540
+ console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(fully local — records + storage on disk)${RESET}`);
541
+ console.log(line);
542
+ console.log(` server ${input.base}`);
543
+ console.log(` data dir ${input.dataDir}`);
544
+ console.log(` render ${DIM}cloud (submitted to the deployed renderer)${RESET}`);
545
+ console.log("");
546
+ console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
547
+ console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
548
+ console.log("");
549
+ console.log(` ${BOLD}Open (pre-authed):${RESET}`);
550
+ console.log(` ${FRONTEND}${input.openUrl}${RESET}`);
551
+ console.log(line);
552
+ console.log("");
553
+ }
554
+ function openInBrowser(url) {
555
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
556
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
365
557
  try {
366
- await fetchVideoAssets({ dir: rootDir, apiKey, shareToken, refetch: parsed.values.refetch });
558
+ spawnSync(opener, args, { stdio: "ignore" });
367
559
  }
368
- catch (error) {
369
- console.warn(`[vidfarm] video assets skipped: ${error instanceof Error ? error.message : String(error)}`);
560
+ catch {
561
+ // Non-fatal: the banner already printed the URL to open manually.
370
562
  }
371
- const devSource = `http://localhost:${port}`;
372
- const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
373
- printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
374
- const { runDevServeCommand } = await import("./dev-serve.js");
375
- await runDevServeCommand(["--dir", rootDir, "--port", String(port)]);
376
563
  }
377
564
  async function runPublishCommand(argv) {
378
565
  const parsed = parseArgs({
@@ -545,7 +732,9 @@ async function fetchCompositionFiles(input) {
545
732
  // video-context.json carries the source video's audio transcript and
546
733
  // per-scene visual descriptions so desktop agents (Claude Code, Codex)
547
734
  // can ground edits in what the video actually says and shows.
548
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json"];
735
+ // cast.json carries the identified recurring people/characters plus their
736
+ // reference-still URLs so agents can reuse "the same person" in new gens.
737
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
549
738
  for (const filename of files) {
550
739
  const target = path.join(input.dir, filename);
551
740
  if (existsSync(target) && !input.refetch) {
@@ -569,10 +758,10 @@ async function fetchVideoAssets(input) {
569
758
  console.log("[vidfarm] no source video URL found in composition — skipping video assets");
570
759
  return;
571
760
  }
572
- // "decomposed video in composition" = the source the composition currently
573
- // renders from (post-ghostcut if applicable). "original" = the raw source the
574
- // decompose ran against, before any processing. They coincide when the fork
575
- // was never ghostcut/subtitle-removed.
761
+ // "decomposed video" = the source the composition currently renders from
762
+ // (caption-free after subtitle removal, if applicable). "original video" =
763
+ // the raw source the decompose ran against, before any processing. They
764
+ // coincide when the fork was never subtitle-removed.
576
765
  const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
577
766
  const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
578
767
  const sameSource = compositionUrl === originalUrl;
@@ -611,19 +800,20 @@ async function fetchVideoAssets(input) {
611
800
  : new Map();
612
801
  const manifest = {
613
802
  generated_by: "vidfarm-devcli",
614
- note: "Local media references for coding agents. `original.mp4` is the raw source video; " +
615
- "`source.mp4` is the video the composition timeline renders from (may differ from the " +
616
- "original after ghostcut subtitle-removal). Scene entries map timeline segments to a still " +
617
- "frame you can open as an image. Run `--refetch` to refresh.",
803
+ note: "Local media references for coding agents. Two video sources: `original.mp4` is the " +
804
+ "ORIGINAL video (raw source, captions intact); `source.mp4` is the DECOMPOSED video, the " +
805
+ "processed caption-free copy the composition timeline renders from (differs from the original " +
806
+ "only when the fork was decomposed with subtitle removal). Scene entries map timeline segments " +
807
+ "to a still frame you can open as an image. Run `--refetch` to refresh.",
618
808
  videos: {
619
809
  decomposed: {
620
- role: "video the composition timeline renders from (decomposed source)",
810
+ role: "decomposed video: processed caption-free source the composition timeline renders from",
621
811
  url: compositionUrl,
622
812
  path: decomposed ? decomposedName : null,
623
813
  bytes: decomposed?.bytes ?? null
624
814
  },
625
815
  original: {
626
- role: "raw original source video (before any processing)",
816
+ role: "original video: raw source before any processing",
627
817
  url: originalUrl,
628
818
  path: original ? original.path : null,
629
819
  bytes: original?.bytes ?? null,
@@ -799,29 +989,6 @@ function buildAuthHeaders(input) {
799
989
  headers["vidfarm-share-token"] = input.shareToken;
800
990
  return headers;
801
991
  }
802
- function printEditorBanner(input) {
803
- const bold = "\x1b[1m";
804
- const green = "\x1b[32m";
805
- const cyan = "\x1b[36m";
806
- const dim = "\x1b[2m";
807
- const reset = "\x1b[0m";
808
- const line = `${dim}${"─".repeat(74)}${reset}`;
809
- console.log("");
810
- console.log(line);
811
- console.log(`${bold}${green} Vidfarm local editor${reset}`);
812
- console.log(line);
813
- console.log(` template ${input.templateId}`);
814
- console.log(` fork ${input.forkId}`);
815
- console.log(` local dir ${input.dir}`);
816
- console.log(` dev-serve http://localhost:${input.port}`);
817
- console.log("");
818
- console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
819
- console.log("");
820
- console.log(` ${bold}Open in browser:${reset}`);
821
- console.log(` ${cyan}${input.editorUrl}${reset}`);
822
- console.log(line);
823
- console.log("");
824
- }
825
992
  function trimTrailingSlash(value) {
826
993
  return value.replace(/\/+$/, "");
827
994
  }
@@ -912,6 +1079,12 @@ async function runInspirationDecomposeCommand(argv) {
912
1079
  body: { user_prompt: parsed.values.prompt ?? null }
913
1080
  });
914
1081
  assertApiOk(result, "inspiration-decompose");
1082
+ if (!ctx.json && result.json?.default_fork_id) {
1083
+ const forkId = result.json.default_fork_id;
1084
+ console.log(result.json.became_default_fork
1085
+ ? `${DIM}This decompose set the template default fork (${forkId}) — everyone now forks off it.${RESET}`
1086
+ : `${DIM}Template already has a default fork (${forkId}); left it unchanged.${RESET}`);
1087
+ }
915
1088
  emitResult(result, ctx.json);
916
1089
  }
917
1090
  // ── Composition lifecycle ───────────────────────────────────────────────────
@@ -1297,4 +1470,145 @@ async function runDownloadCommand(argv) {
1297
1470
  console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
1298
1471
  }
1299
1472
  }
1473
+ // Browse the user's My Files library — the persistent per-user asset store
1474
+ // (videos, images, audio, docs) organized into virtual folders. `files` lists
1475
+ // them (optionally scoped to a folder); `get-file` resolves one attachment id to
1476
+ // its durable URL and either streams the bytes to disk or prints text contents.
1477
+ // This is the same filesystem the /editor AI agent browses via the browse_files
1478
+ // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
1479
+ async function runFilesCommand(argv) {
1480
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
1481
+ const ctx = commonContext(parsed.values);
1482
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
1483
+ assertApiOk(result, "files");
1484
+ const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
1485
+ if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
1486
+ result.json = {
1487
+ ...result.json,
1488
+ attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
1489
+ };
1490
+ }
1491
+ emitResult(result, ctx.json);
1492
+ }
1493
+ async function runGetFileCommand(argv) {
1494
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
1495
+ const fileId = parsed.positionals[0];
1496
+ if (!fileId)
1497
+ throw new Error("get-file requires a file id (run `files` to list ids).");
1498
+ const ctx = commonContext(parsed.values);
1499
+ const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
1500
+ assertApiOk(list, "get-file");
1501
+ const attachments = Array.isArray(list.json?.attachments) ? list.json.attachments : [];
1502
+ const match = attachments.find((file) => file?.id === fileId) ?? attachments.find((file) => file?.fileName === fileId);
1503
+ if (!match)
1504
+ throw new Error(`No file with id or name "${fileId}" in My Files. Run \`files\` to list ids.`);
1505
+ const viewUrl = match.viewUrl;
1506
+ if (!viewUrl)
1507
+ throw new Error(`File "${match.fileName}" has no resolvable URL.`);
1508
+ const absoluteUrl = new URL(viewUrl, ctx.host).toString();
1509
+ const res = await fetch(absoluteUrl, { headers: buildAuthHeaders(ctx.auth) });
1510
+ if (!res.ok || !res.body)
1511
+ throw new Error(`get-file failed: ${res.status} ${res.statusText} (${absoluteUrl}).`);
1512
+ if (parsed.values.print) {
1513
+ const text = await res.text();
1514
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1515
+ return;
1516
+ }
1517
+ const dest = parsed.positionals[1]
1518
+ ? path.resolve(process.cwd(), parsed.positionals[1])
1519
+ : path.resolve(process.cwd(), match.fileName || path.basename(new URL(absoluteUrl).pathname) || "download.bin");
1520
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
1521
+ const bytes = safeSize(dest);
1522
+ if (ctx.json) {
1523
+ printJson({ ok: true, file_id: match.id, file_name: match.fileName, url: absoluteUrl, path: dest, bytes });
1524
+ }
1525
+ else {
1526
+ console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
1527
+ }
1528
+ }
1529
+ // ── Agent skill ───────────────────────────────────────────────────────────────
1530
+ // Install the latest director skill onto disk as a Claude Code / agent skill so
1531
+ // the user's AI agent can read SKILL.director.md and act. We pull the freshest
1532
+ // copy from the live host (`GET /skill/<name>`) and fall back to the copy that
1533
+ // shipped inside this npm package when offline. Written as `<skill>/SKILL.md`.
1534
+ // Walk up from this module's directory to find a bundled skill file. Handles
1535
+ // both the published layout (dist/src/cli.js → package root) and running from
1536
+ // source (src/cli.ts → repo root).
1537
+ function locateBundledSkill(filename) {
1538
+ let dir = path.dirname(fileURLToPath(import.meta.url));
1539
+ for (let i = 0; i < 6; i += 1) {
1540
+ const candidate = path.join(dir, filename);
1541
+ if (existsSync(candidate))
1542
+ return candidate;
1543
+ const parent = path.dirname(dir);
1544
+ if (parent === dir)
1545
+ break;
1546
+ dir = parent;
1547
+ }
1548
+ return null;
1549
+ }
1550
+ async function fetchSkillContents(host, target) {
1551
+ try {
1552
+ const res = await fetch(new URL(target.route, host));
1553
+ if (res.ok) {
1554
+ const contents = await res.text();
1555
+ if (contents.trim())
1556
+ return { contents, source: "remote" };
1557
+ }
1558
+ }
1559
+ catch {
1560
+ // fall through to the bundled copy below
1561
+ }
1562
+ const bundledPath = locateBundledSkill(target.bundled);
1563
+ if (bundledPath) {
1564
+ return { contents: readFileSync(bundledPath, "utf8"), source: "bundled" };
1565
+ }
1566
+ throw new Error(`Could not fetch ${target.bundled} from ${host} and no bundled copy was found.`);
1567
+ }
1568
+ async function runUpdateSkillCommand(argv) {
1569
+ const parsed = parseArgs({
1570
+ args: argv,
1571
+ allowPositionals: false,
1572
+ options: {
1573
+ ...commonOptions(),
1574
+ global: { type: "boolean", default: false },
1575
+ dir: { type: "string" },
1576
+ platform: { type: "boolean", default: false },
1577
+ print: { type: "boolean", default: false }
1578
+ }
1579
+ });
1580
+ const ctx = commonContext(parsed.values);
1581
+ const skillNames = ["vidfarm-director", ...(parsed.values.platform ? ["vidfarm-platform"] : [])];
1582
+ if (parsed.values.print) {
1583
+ for (const name of skillNames) {
1584
+ const { contents } = await fetchSkillContents(ctx.host, SKILL_TARGETS[name]);
1585
+ process.stdout.write(contents.endsWith("\n") ? contents : `${contents}\n`);
1586
+ }
1587
+ return;
1588
+ }
1589
+ const skillsRoot = parsed.values.dir
1590
+ ? path.resolve(process.cwd(), String(parsed.values.dir))
1591
+ : parsed.values.global
1592
+ ? path.join(homedir(), ".claude", "skills")
1593
+ : path.resolve(process.cwd(), ".claude", "skills");
1594
+ const written = [];
1595
+ for (const name of skillNames) {
1596
+ const { contents, source } = await fetchSkillContents(ctx.host, SKILL_TARGETS[name]);
1597
+ const destDir = path.join(skillsRoot, name);
1598
+ mkdirSync(destDir, { recursive: true });
1599
+ const destPath = path.join(destDir, "SKILL.md");
1600
+ writeFileSync(destPath, contents, "utf8");
1601
+ written.push({ skill: name, path: destPath, source, bytes: Buffer.byteLength(contents, "utf8") });
1602
+ }
1603
+ if (ctx.json) {
1604
+ printJson({ ok: true, skillsRoot, installed: written });
1605
+ return;
1606
+ }
1607
+ for (const entry of written) {
1608
+ const tag = entry.source === "bundled" ? " (offline — bundled copy)" : "";
1609
+ console.log(`${GREEN}Installed ${entry.skill} skill${tag} → ${entry.path}${RESET}`);
1610
+ }
1611
+ console.log("");
1612
+ console.log(` ${BOLD}Your AI agent can now use the "vidfarm-director" skill.${RESET}`);
1613
+ }
1300
1614
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src/cli.js",
12
- "dist/src/dev-serve.js",
13
12
  "README.md",
14
13
  "SKILL.director.md",
15
14
  "SKILL.platform.md",
@@ -1,340 +0,0 @@
1
- import { createServer } from "node:http";
2
- import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { parseArgs } from "node:util";
5
- // vidfarm dev-serve — a tiny HTTP layer that lets the browser editor point at
6
- // a local directory of composition files instead of the deployed API. The
7
- // editor detects dev mode from `?dev=<url>` (or localStorage) and rewrites
8
- // its fork API calls to hit this server. File changes fan out via SSE so the
9
- // editor can reload the composition on save-from-disk (e.g. Claude Code
10
- // edits the file directly).
11
- const CORS_HEADERS = {
12
- "access-control-allow-origin": "*",
13
- "access-control-allow-methods": "GET,PUT,PATCH,POST,OPTIONS",
14
- // The editor page (vidfarm.cc) fetches us cross-origin and its Sentry
15
- // instrumentation attaches distributed-tracing headers (sentry-trace,
16
- // baggage). List them so the preflight passes; the OPTIONS handler also
17
- // echoes whatever the browser actually asks for, which is the real guard.
18
- "access-control-allow-headers": "content-type,authorization,vidfarm-api-key,vidfarm-share-token,sentry-trace,baggage",
19
- "access-control-max-age": "600"
20
- };
21
- // Cap request bodies at 8MB so a runaway or malicious client can't OOM the
22
- // dev server. A composition.html + inlined asset is normally <1MB.
23
- const MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
24
- // Ignore filesystem events for this long after we write from an HTTP handler,
25
- // so the editor's own PUT/PATCH doesn't trigger a self-reload loop. `fs.watch`
26
- // on macOS fires 1–3 events per rename, plus a tail event from the editor's
27
- // auto-format step.
28
- const SELF_WRITE_SUPPRESS_MS = 500;
29
- // Bump on every new dev-serve process so a reconnecting SSE client can tell
30
- // whether the server restarted (and therefore whether it must resync files).
31
- const SERVER_INSTANCE_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
32
- export async function runDevServeCommand(argv) {
33
- const parsed = parseArgs({
34
- args: argv,
35
- options: {
36
- dir: { type: "string", default: "." },
37
- port: { type: "string", default: "4321" }
38
- }
39
- });
40
- const rootDir = path.resolve(process.cwd(), parsed.values.dir);
41
- const port = Number(parsed.values.port);
42
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
43
- throw new Error(`Invalid --port: ${parsed.values.port}`);
44
- }
45
- if (!existsSync(rootDir)) {
46
- mkdirSync(rootDir, { recursive: true });
47
- }
48
- let nextClientId = 1;
49
- let mutationCursor = 0;
50
- const clients = new Set();
51
- const selfWriteSuppressUntil = new Map();
52
- function broadcast(event, data) {
53
- const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
54
- // Snapshot the client set before iterating: `client.res.write` can
55
- // synchronously destroy the response (broken pipe), which mutates the
56
- // Set mid-iteration and can skip healthy clients.
57
- for (const client of [...clients]) {
58
- try {
59
- client.res.write(payload);
60
- }
61
- catch {
62
- clients.delete(client);
63
- }
64
- }
65
- }
66
- function isSelfWriteSuppressed(filename) {
67
- if (!filename)
68
- return false;
69
- const until = selfWriteSuppressUntil.get(filename);
70
- if (!until)
71
- return false;
72
- if (Date.now() > until) {
73
- selfWriteSuppressUntil.delete(filename);
74
- return false;
75
- }
76
- return true;
77
- }
78
- function suppressReloadFromSelfWrite(filePath) {
79
- const rel = path.relative(rootDir, filePath);
80
- if (!rel || rel.startsWith(".."))
81
- return;
82
- // fs.watch reports filenames relative to the watched root — match both the
83
- // basename and the relative path, since macOS emits either form.
84
- selfWriteSuppressUntil.set(rel, Date.now() + SELF_WRITE_SUPPRESS_MS);
85
- selfWriteSuppressUntil.set(path.basename(filePath), Date.now() + SELF_WRITE_SUPPRESS_MS);
86
- }
87
- // fs.watch fires on any child change. Coalesce bursts so a single save
88
- // doesn't spam the editor with 3 reload events. Also drop events for files
89
- // we JUST wrote ourselves so PUT/PATCH doesn't loop into a client reload.
90
- let reloadTimer = null;
91
- let pendingReloadFile = null;
92
- const scheduleReload = (filename) => {
93
- if (isSelfWriteSuppressed(filename))
94
- return;
95
- pendingReloadFile = filename ?? pendingReloadFile;
96
- if (reloadTimer)
97
- clearTimeout(reloadTimer);
98
- reloadTimer = setTimeout(() => {
99
- reloadTimer = null;
100
- const file = pendingReloadFile;
101
- pendingReloadFile = null;
102
- mutationCursor += 1;
103
- broadcast("reload", { file, at: Date.now(), cursor: mutationCursor });
104
- console.log(`[dev-serve] reload → ${file ?? "(unknown)"}`);
105
- }, 80);
106
- };
107
- try {
108
- watch(rootDir, { recursive: true }, (_type, filename) => {
109
- scheduleReload(filename ?? null);
110
- });
111
- }
112
- catch (error) {
113
- console.warn(`[dev-serve] watch failed: ${error instanceof Error ? error.message : String(error)}`);
114
- console.warn("[dev-serve] file changes will NOT trigger editor reloads. On Linux, install inotify-tools or run inside a supported filesystem.");
115
- }
116
- const server = createServer((req, res) => handleRequest(req, res, {
117
- rootDir,
118
- clients,
119
- clientId: nextClientId++,
120
- mutationCursor: () => mutationCursor,
121
- suppressReloadFromSelfWrite
122
- }));
123
- server.on("error", (error) => {
124
- if (error.code === "EADDRINUSE") {
125
- console.error(`[dev-serve] port ${port} is already in use.`);
126
- console.error(`[dev-serve] pass --port <n> to pick a different port, or stop the process bound to ${port}.`);
127
- process.exit(1);
128
- }
129
- console.error(`[dev-serve] server error: ${error.message}`);
130
- process.exit(1);
131
- });
132
- server.listen(port, () => {
133
- console.log(`[dev-serve] serving ${rootDir}`);
134
- console.log(`[dev-serve] listening on http://localhost:${port}`);
135
- console.log(`[dev-serve] editor: open with ?dev=${encodeURIComponent(`http://localhost:${port}`)}`);
136
- });
137
- let shuttingDown = false;
138
- const shutdown = () => {
139
- if (shuttingDown)
140
- return;
141
- shuttingDown = true;
142
- console.log("\n[dev-serve] shutting down");
143
- for (const client of clients) {
144
- try {
145
- client.res.end();
146
- }
147
- catch { }
148
- }
149
- clients.clear();
150
- server.close(() => process.exit(0));
151
- // Fallback: if in-flight sockets keep the server open, force-exit after 2s.
152
- setTimeout(() => process.exit(0), 2000).unref();
153
- };
154
- process.on("SIGINT", shutdown);
155
- process.on("SIGTERM", shutdown);
156
- }
157
- function handleRequest(req, res, ctx) {
158
- const method = (req.method ?? "GET").toUpperCase();
159
- const url = new URL(req.url ?? "/", `http://localhost`);
160
- const pathname = url.pathname;
161
- if (method === "OPTIONS") {
162
- // Echo the exact headers the browser requested so a preflight never fails
163
- // over an unexpected header (Sentry tracing, etc.). This is a local dev
164
- // tool, so permissive CORS is fine.
165
- const requested = req.headers["access-control-request-headers"];
166
- res.writeHead(204, {
167
- ...CORS_HEADERS,
168
- "access-control-allow-headers": typeof requested === "string" && requested ? requested : CORS_HEADERS["access-control-allow-headers"]
169
- });
170
- res.end();
171
- return;
172
- }
173
- if (pathname === "/_events" && method === "GET") {
174
- res.writeHead(200, {
175
- ...CORS_HEADERS,
176
- "content-type": "text/event-stream",
177
- "cache-control": "no-cache",
178
- connection: "keep-alive",
179
- "x-accel-buffering": "no"
180
- });
181
- // Include instance id + current cursor so a reconnecting client can tell
182
- // if the server restarted (missed events) or if it just needs to catch up.
183
- res.write(`event: hello\ndata: ${JSON.stringify({
184
- at: Date.now(),
185
- instance: SERVER_INSTANCE_ID,
186
- cursor: ctx.mutationCursor()
187
- })}\n\n`);
188
- const client = { res, id: ctx.clientId };
189
- ctx.clients.add(client);
190
- // Heartbeat keeps intermediate proxies (and iOS Safari) from timing out
191
- // the long-lived SSE connection.
192
- const heartbeat = setInterval(() => {
193
- try {
194
- res.write(`: heartbeat ${Date.now()}\n\n`);
195
- }
196
- catch { }
197
- }, 20_000);
198
- const cleanup = () => {
199
- clearInterval(heartbeat);
200
- ctx.clients.delete(client);
201
- };
202
- req.on("close", cleanup);
203
- req.on("error", cleanup);
204
- return;
205
- }
206
- const filePath = resolveFilePath(ctx.rootDir, pathname);
207
- if (!filePath) {
208
- respondJson(res, 404, { ok: false, error: `Not found: ${pathname}` });
209
- return;
210
- }
211
- if (method === "GET") {
212
- if (!existsSync(filePath)) {
213
- respondJson(res, 404, { ok: false, error: `Missing ${path.basename(filePath)}` });
214
- return;
215
- }
216
- try {
217
- const body = readFileSync(filePath);
218
- const stat = statSync(filePath);
219
- const contentType = pathname.endsWith(".html") ? "text/html; charset=utf-8" : "application/json";
220
- res.writeHead(200, {
221
- ...CORS_HEADERS,
222
- "content-type": contentType,
223
- "cache-control": "no-store",
224
- etag: `"${stat.size}-${stat.mtimeMs}"`
225
- });
226
- res.end(body);
227
- }
228
- catch (error) {
229
- respondJson(res, 500, { ok: false, error: `Read failed: ${error instanceof Error ? error.message : String(error)}` });
230
- }
231
- return;
232
- }
233
- if (method === "PUT" && pathname.endsWith("/composition.html")) {
234
- readBody(req).then((body) => {
235
- const html = body.toString("utf8");
236
- if (!html.includes("data-composition-id=")) {
237
- respondJson(res, 400, { ok: false, error: "Composition HTML is missing data-composition-id" });
238
- return;
239
- }
240
- try {
241
- mkdirSync(path.dirname(filePath), { recursive: true });
242
- ctx.suppressReloadFromSelfWrite(filePath);
243
- writeFileSync(filePath, html, "utf8");
244
- respondJson(res, 200, { ok: true, bytes: html.length });
245
- }
246
- catch (error) {
247
- respondJson(res, 500, { ok: false, error: `Write failed: ${error instanceof Error ? error.message : String(error)}` });
248
- }
249
- }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
250
- return;
251
- }
252
- if (method === "PATCH" && pathname.endsWith("/composition.json")) {
253
- readBody(req).then((body) => {
254
- try {
255
- const patch = JSON.parse(body.toString("utf8") || "{}");
256
- const existing = existsSync(filePath) ? JSON.parse(readFileSync(filePath, "utf8") || "{}") : {};
257
- const merged = { ...existing, ...patch };
258
- mkdirSync(path.dirname(filePath), { recursive: true });
259
- ctx.suppressReloadFromSelfWrite(filePath);
260
- writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
261
- respondJson(res, 200, merged);
262
- }
263
- catch (error) {
264
- respondJson(res, 400, { ok: false, error: `PATCH failed: ${error instanceof Error ? error.message : String(error)}` });
265
- }
266
- }).catch((error) => respondJson(res, 400, { ok: false, error: String(error) }));
267
- return;
268
- }
269
- respondJson(res, 405, { ok: false, error: `Method ${method} not allowed on ${pathname}` });
270
- }
271
- // Restrict served paths to the working/version composition files. Anything
272
- // else 404s so this server isn't a random static host. Also canonicalise the
273
- // resolved path so `..` segments cannot escape rootDir even through a future
274
- // refactor of the allowlist.
275
- function resolveFilePath(rootDir, pathname) {
276
- const clean = pathname.replace(/^\/+/, "");
277
- const parts = clean.split("/").filter(Boolean);
278
- if (parts.length === 0)
279
- return null;
280
- const filename = parts[parts.length - 1];
281
- const allowed = ["composition.html", "composition.json", "manifest.json"];
282
- if (!allowed.includes(filename))
283
- return null;
284
- let candidate;
285
- // Allow: `composition.html`, `versions/1/composition.html`.
286
- if (parts.length === 1)
287
- candidate = path.join(rootDir, filename);
288
- else if (parts.length === 3 && parts[0] === "versions" && /^\d+$/.test(parts[1])) {
289
- candidate = path.join(rootDir, "versions", parts[1], filename);
290
- }
291
- else {
292
- return null;
293
- }
294
- const resolved = path.resolve(candidate);
295
- const rootResolved = path.resolve(rootDir);
296
- const rel = path.relative(rootResolved, resolved);
297
- if (rel.startsWith("..") || path.isAbsolute(rel))
298
- return null;
299
- return resolved;
300
- }
301
- function respondJson(res, status, body) {
302
- res.writeHead(status, { ...CORS_HEADERS, "content-type": "application/json" });
303
- res.end(JSON.stringify(body));
304
- }
305
- function readBody(req) {
306
- return new Promise((resolve, reject) => {
307
- const chunks = [];
308
- let total = 0;
309
- let settled = false;
310
- // Guard against a slow client stringing the connection open forever.
311
- const timeout = setTimeout(() => {
312
- if (settled)
313
- return;
314
- settled = true;
315
- req.destroy();
316
- reject(new Error("Request body read timed out after 15s."));
317
- }, 15_000);
318
- const done = (fn) => {
319
- if (settled)
320
- return;
321
- settled = true;
322
- clearTimeout(timeout);
323
- fn();
324
- };
325
- req.on("data", (chunk) => {
326
- if (settled)
327
- return;
328
- total += chunk.length;
329
- if (total > MAX_REQUEST_BODY_BYTES) {
330
- done(() => reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`)));
331
- req.destroy();
332
- return;
333
- }
334
- chunks.push(chunk);
335
- });
336
- req.on("end", () => done(() => resolve(Buffer.concat(chunks))));
337
- req.on("error", (error) => done(() => reject(error)));
338
- });
339
- }
340
- //# sourceMappingURL=dev-serve.js.map