@mevdragon/vidfarm-devcli 0.4.1 → 0.5.1
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 +123 -41
- package/SKILL.platform.md +36 -35
- package/dist/src/cli.js +850 -97
- package/package.json +1 -6
- package/dist/src/dev-serve.js +0 -340
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -64,9 +63,6 @@
|
|
|
64
63
|
"@fontsource/tiktok-sans": "^5.2.4",
|
|
65
64
|
"@hono/node-server": "^1.14.4",
|
|
66
65
|
"@hyperframes/aws-lambda": "^0.7.21",
|
|
67
|
-
"@remotion/bundler": "4.0.355",
|
|
68
|
-
"@remotion/lambda": "4.0.355",
|
|
69
|
-
"@remotion/renderer": "4.0.355",
|
|
70
66
|
"@sentry/aws-serverless": "^10.56.0",
|
|
71
67
|
"@sentry/hono": "^10.56.0",
|
|
72
68
|
"@sentry/node": "^10.56.0",
|
|
@@ -82,7 +78,6 @@
|
|
|
82
78
|
"linkedom": "^0.18.12",
|
|
83
79
|
"react": "^18.3.1",
|
|
84
80
|
"react-dom": "^18.3.1",
|
|
85
|
-
"remotion": "4.0.355",
|
|
86
81
|
"sharp": "^0.34.5",
|
|
87
82
|
"zod": "^3.25.28"
|
|
88
83
|
},
|
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
|