@mevdragon/vidfarm-devcli 0.4.1 → 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 +9 -9
- package/SKILL.director.md +17 -27
- package/SKILL.platform.md +22 -25
- package/dist/src/cli.js +301 -91
- package/package.json +1 -2
- package/dist/src/dev-serve.js +0 -340
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:
|
|
38
|
+
## Local editor: `vidfarm serve`
|
|
39
39
|
|
|
40
|
-
|
|
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
|
|
44
|
-
# →
|
|
45
|
-
# →
|
|
46
|
-
# →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)**:
|
|
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 (
|
|
384
|
+
## Local dev loop (`vidfarm serve`)
|
|
385
385
|
|
|
386
|
-
|
|
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
|
-
# →
|
|
391
|
-
# →
|
|
392
|
-
# →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
356
|
-
-
|
|
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`
|
|
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
|
-
#
|
|
368
|
-
vidfarm-devcli <template_id>
|
|
369
|
-
# →
|
|
370
|
-
# →
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
@@ -7,10 +7,11 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
-
// vidfarm-devcli —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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.
|
|
14
15
|
const DEFAULT_HOST = "https://vidfarm.cc";
|
|
15
16
|
const DEFAULT_PORT = 4321;
|
|
16
17
|
// Agent skill files the `update-skill` command can install from the live host
|
|
@@ -38,9 +39,21 @@ Usage:
|
|
|
38
39
|
vidfarm --help
|
|
39
40
|
|
|
40
41
|
Local editor loop:
|
|
41
|
-
|
|
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)
|
|
42
56
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
43
|
-
dev-serve Serve an existing local dir only (no fetch)
|
|
44
57
|
|
|
45
58
|
Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
46
59
|
discover List the template/inspiration feed → GET /discover/feed
|
|
@@ -51,7 +64,8 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
51
64
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
52
65
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
53
66
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
54
|
-
ghostcut <forkId> Read
|
|
67
|
+
ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
|
|
68
|
+
decomposed (caption-free), non-billing
|
|
55
69
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
56
70
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
57
71
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/export
|
|
@@ -83,7 +97,12 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
83
97
|
|
|
84
98
|
Files (multi-step flows the devcli handles for you):
|
|
85
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
|
|
86
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
|
|
87
106
|
|
|
88
107
|
Escape hatch — call ANY route directly:
|
|
89
108
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
@@ -99,11 +118,12 @@ Common options (any command):
|
|
|
99
118
|
--share <token> Share token for a shared fork (public read)
|
|
100
119
|
--json Print only the raw JSON response (agent-friendly)
|
|
101
120
|
|
|
102
|
-
|
|
103
|
-
--port <n> Local
|
|
104
|
-
--dir <path> Local
|
|
105
|
-
--
|
|
106
|
-
--
|
|
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
|
|
107
127
|
|
|
108
128
|
Publish-mode options:
|
|
109
129
|
--message <text> Message attached to the published version snapshot
|
|
@@ -122,13 +142,11 @@ async function main() {
|
|
|
122
142
|
const command = argv[0];
|
|
123
143
|
const rest = argv.slice(1);
|
|
124
144
|
switch (command) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
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":
|
|
130
148
|
case "edit":
|
|
131
|
-
await
|
|
149
|
+
await runServeCommand(rest);
|
|
132
150
|
return;
|
|
133
151
|
case "publish":
|
|
134
152
|
await runPublishCommand(rest);
|
|
@@ -219,10 +237,16 @@ async function main() {
|
|
|
219
237
|
case "download":
|
|
220
238
|
await runDownloadCommand(rest);
|
|
221
239
|
return;
|
|
240
|
+
case "files":
|
|
241
|
+
await runFilesCommand(rest);
|
|
242
|
+
return;
|
|
243
|
+
case "get-file":
|
|
244
|
+
await runGetFileCommand(rest);
|
|
245
|
+
return;
|
|
222
246
|
default:
|
|
223
247
|
if (!command.startsWith("-")) {
|
|
224
|
-
// Positional template id → default
|
|
225
|
-
await
|
|
248
|
+
// Positional template id → default to booting the local editor.
|
|
249
|
+
await runServeCommand(argv);
|
|
226
250
|
return;
|
|
227
251
|
}
|
|
228
252
|
throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
|
|
@@ -337,61 +361,205 @@ function collectKeyValues(entries) {
|
|
|
337
361
|
}
|
|
338
362
|
return out;
|
|
339
363
|
}
|
|
340
|
-
|
|
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) {
|
|
341
369
|
const parsed = parseArgs({
|
|
342
370
|
args: argv,
|
|
343
371
|
allowPositionals: true,
|
|
344
372
|
options: {
|
|
345
|
-
port: { type: "string", default:
|
|
373
|
+
port: { type: "string", default: "3000" },
|
|
346
374
|
dir: { type: "string" },
|
|
347
|
-
|
|
375
|
+
key: { type: "string" },
|
|
348
376
|
fork: { type: "string" },
|
|
377
|
+
host: { type: "string", default: DEFAULT_HOST },
|
|
349
378
|
"api-key": { type: "string" },
|
|
350
379
|
share: { type: "string" },
|
|
351
|
-
refetch: { type: "boolean", default: false }
|
|
380
|
+
refetch: { type: "boolean", default: false },
|
|
381
|
+
open: { type: "boolean", default: true },
|
|
382
|
+
"no-open": { type: "boolean", default: false }
|
|
352
383
|
}
|
|
353
384
|
});
|
|
354
|
-
const templateId = parsed.positionals[0];
|
|
355
|
-
if (!templateId) {
|
|
356
|
-
throw new Error(`edit requires a template id.\n\n${HELP}`);
|
|
357
|
-
}
|
|
358
385
|
const port = Number(parsed.values.port);
|
|
359
386
|
if (!Number.isFinite(port) || port <= 0) {
|
|
360
387
|
throw new Error(`Invalid --port: ${parsed.values.port}`);
|
|
361
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;
|
|
362
400
|
const host = trimTrailingSlash(parsed.values.host);
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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];
|
|
384
557
|
try {
|
|
385
|
-
|
|
558
|
+
spawnSync(opener, args, { stdio: "ignore" });
|
|
386
559
|
}
|
|
387
|
-
catch
|
|
388
|
-
|
|
560
|
+
catch {
|
|
561
|
+
// Non-fatal: the banner already printed the URL to open manually.
|
|
389
562
|
}
|
|
390
|
-
const devSource = `http://localhost:${port}`;
|
|
391
|
-
const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
|
|
392
|
-
printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
|
|
393
|
-
const { runDevServeCommand } = await import("./dev-serve.js");
|
|
394
|
-
await runDevServeCommand(["--dir", rootDir, "--port", String(port)]);
|
|
395
563
|
}
|
|
396
564
|
async function runPublishCommand(argv) {
|
|
397
565
|
const parsed = parseArgs({
|
|
@@ -564,7 +732,9 @@ async function fetchCompositionFiles(input) {
|
|
|
564
732
|
// video-context.json carries the source video's audio transcript and
|
|
565
733
|
// per-scene visual descriptions so desktop agents (Claude Code, Codex)
|
|
566
734
|
// can ground edits in what the video actually says and shows.
|
|
567
|
-
|
|
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"];
|
|
568
738
|
for (const filename of files) {
|
|
569
739
|
const target = path.join(input.dir, filename);
|
|
570
740
|
if (existsSync(target) && !input.refetch) {
|
|
@@ -588,10 +758,10 @@ async function fetchVideoAssets(input) {
|
|
|
588
758
|
console.log("[vidfarm] no source video URL found in composition — skipping video assets");
|
|
589
759
|
return;
|
|
590
760
|
}
|
|
591
|
-
// "decomposed video
|
|
592
|
-
//
|
|
593
|
-
// decompose ran against, before any processing. They
|
|
594
|
-
// was never
|
|
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.
|
|
595
765
|
const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
|
|
596
766
|
const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
|
|
597
767
|
const sameSource = compositionUrl === originalUrl;
|
|
@@ -630,19 +800,20 @@ async function fetchVideoAssets(input) {
|
|
|
630
800
|
: new Map();
|
|
631
801
|
const manifest = {
|
|
632
802
|
generated_by: "vidfarm-devcli",
|
|
633
|
-
note: "Local media references for coding agents. `original.mp4` is the
|
|
634
|
-
"`source.mp4` is the video the
|
|
635
|
-
"
|
|
636
|
-
"
|
|
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.",
|
|
637
808
|
videos: {
|
|
638
809
|
decomposed: {
|
|
639
|
-
role: "video the composition timeline renders from
|
|
810
|
+
role: "decomposed video: processed caption-free source the composition timeline renders from",
|
|
640
811
|
url: compositionUrl,
|
|
641
812
|
path: decomposed ? decomposedName : null,
|
|
642
813
|
bytes: decomposed?.bytes ?? null
|
|
643
814
|
},
|
|
644
815
|
original: {
|
|
645
|
-
role: "raw
|
|
816
|
+
role: "original video: raw source before any processing",
|
|
646
817
|
url: originalUrl,
|
|
647
818
|
path: original ? original.path : null,
|
|
648
819
|
bytes: original?.bytes ?? null,
|
|
@@ -818,29 +989,6 @@ function buildAuthHeaders(input) {
|
|
|
818
989
|
headers["vidfarm-share-token"] = input.shareToken;
|
|
819
990
|
return headers;
|
|
820
991
|
}
|
|
821
|
-
function printEditorBanner(input) {
|
|
822
|
-
const bold = "\x1b[1m";
|
|
823
|
-
const green = "\x1b[32m";
|
|
824
|
-
const cyan = "\x1b[36m";
|
|
825
|
-
const dim = "\x1b[2m";
|
|
826
|
-
const reset = "\x1b[0m";
|
|
827
|
-
const line = `${dim}${"─".repeat(74)}${reset}`;
|
|
828
|
-
console.log("");
|
|
829
|
-
console.log(line);
|
|
830
|
-
console.log(`${bold}${green} Vidfarm local editor${reset}`);
|
|
831
|
-
console.log(line);
|
|
832
|
-
console.log(` template ${input.templateId}`);
|
|
833
|
-
console.log(` fork ${input.forkId}`);
|
|
834
|
-
console.log(` local dir ${input.dir}`);
|
|
835
|
-
console.log(` dev-serve http://localhost:${input.port}`);
|
|
836
|
-
console.log("");
|
|
837
|
-
console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
|
|
838
|
-
console.log("");
|
|
839
|
-
console.log(` ${bold}Open in browser:${reset}`);
|
|
840
|
-
console.log(` ${cyan}${input.editorUrl}${reset}`);
|
|
841
|
-
console.log(line);
|
|
842
|
-
console.log("");
|
|
843
|
-
}
|
|
844
992
|
function trimTrailingSlash(value) {
|
|
845
993
|
return value.replace(/\/+$/, "");
|
|
846
994
|
}
|
|
@@ -931,6 +1079,12 @@ async function runInspirationDecomposeCommand(argv) {
|
|
|
931
1079
|
body: { user_prompt: parsed.values.prompt ?? null }
|
|
932
1080
|
});
|
|
933
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
|
+
}
|
|
934
1088
|
emitResult(result, ctx.json);
|
|
935
1089
|
}
|
|
936
1090
|
// ── Composition lifecycle ───────────────────────────────────────────────────
|
|
@@ -1316,6 +1470,62 @@ async function runDownloadCommand(argv) {
|
|
|
1316
1470
|
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
1317
1471
|
}
|
|
1318
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
|
+
}
|
|
1319
1529
|
// ── Agent skill ───────────────────────────────────────────────────────────────
|
|
1320
1530
|
// Install the latest director skill onto disk as a Claude Code / agent skill so
|
|
1321
1531
|
// the user's AI agent can read SKILL.director.md and act. We pull the freshest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "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",
|
package/dist/src/dev-serve.js
DELETED
|
@@ -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
|