@vivipilot/cli 0.1.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.
Files changed (58) hide show
  1. package/README.md +57 -0
  2. package/dist/api.d.ts +86 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +77 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/args.d.ts +11 -0
  7. package/dist/args.d.ts.map +1 -0
  8. package/dist/args.js +53 -0
  9. package/dist/args.js.map +1 -0
  10. package/dist/browser.d.ts +31 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +162 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.d.ts +12 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +536 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config.d.ts +15 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +58 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/errors.d.ts +6 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +12 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/manifest.d.ts +40 -0
  31. package/dist/manifest.d.ts.map +1 -0
  32. package/dist/manifest.js +90 -0
  33. package/dist/manifest.js.map +1 -0
  34. package/dist/mcp.d.ts +13 -0
  35. package/dist/mcp.d.ts.map +1 -0
  36. package/dist/mcp.js +392 -0
  37. package/dist/mcp.js.map +1 -0
  38. package/dist/render.d.ts +21 -0
  39. package/dist/render.d.ts.map +1 -0
  40. package/dist/render.js +369 -0
  41. package/dist/render.js.map +1 -0
  42. package/package.json +42 -0
  43. package/src/api.ts +163 -0
  44. package/src/args.test.ts +21 -0
  45. package/src/args.ts +64 -0
  46. package/src/browser.test.ts +103 -0
  47. package/src/browser.ts +174 -0
  48. package/src/cli.ts +656 -0
  49. package/src/config.test.ts +30 -0
  50. package/src/config.ts +71 -0
  51. package/src/errors.ts +14 -0
  52. package/src/index.ts +25 -0
  53. package/src/manifest.test.ts +105 -0
  54. package/src/manifest.ts +126 -0
  55. package/src/mcp.test.ts +48 -0
  56. package/src/mcp.ts +438 -0
  57. package/src/render.ts +424 -0
  58. package/tsconfig.json +26 -0
package/src/render.ts ADDED
@@ -0,0 +1,424 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createServer, type Server } from "node:http";
3
+ import { readFile, writeFile, access, stat, mkdir } from "node:fs/promises";
4
+ import { constants, openSync, writeSync, closeSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import type { CliConfig, Env } from "./config.js";
8
+ import { CliError } from "./errors.js";
9
+ import { resolveHeadlessBrowser, headlessBrowserArgs, BrowserNotFoundError } from "./browser.js";
10
+ import { verifyManifestFile, checkManifestRevocationOnline, assertNotRevoked, checkRenderEntitlement, type RenderEntitlementRequest } from "./manifest.js";
11
+
12
+ export type RenderOptions = {
13
+ manifestPath: string;
14
+ outPath: string;
15
+ format?: "mp4" | "webm" | "gif" | "mov";
16
+ width?: number;
17
+ height?: number;
18
+ fps?: number;
19
+ scale?: number;
20
+ transparent?: boolean;
21
+ verifyOnly?: boolean;
22
+ };
23
+
24
+ type RenderResult = {
25
+ ok: boolean;
26
+ outPath: string;
27
+ size: number;
28
+ manifestId: string;
29
+ };
30
+
31
+ const DEFAULT_RENDER_URL = "http://localhost:4001/headless-render";
32
+ const DEFAULT_TIMEOUT_MS = 300_000;
33
+
34
+ function startCallbackServer(outPath: string): Promise<{ server: Server; port: number }> {
35
+ return new Promise((resolve, reject) => {
36
+ let renderedBlob: Buffer | null = null;
37
+ let renderError: string | null = null;
38
+
39
+ const server = createServer(async (req, res) => {
40
+ res.setHeader("Access-Control-Allow-Origin", "*");
41
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
42
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
43
+
44
+ if (req.method === "OPTIONS") {
45
+ res.writeHead(204);
46
+ res.end();
47
+ return;
48
+ }
49
+
50
+ const url = new URL(req.url ?? "/", `http://localhost`);
51
+
52
+ if (url.pathname === "/manifest.json" && req.method === "GET") {
53
+ try {
54
+ const manifestJson = await readFile(outPath.replace(/\.mp4$|\.webm$|\.gif$|\.mov$/, ".vivi.json"), "utf8");
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(manifestJson);
57
+ } catch {
58
+ res.writeHead(404);
59
+ res.end("Manifest not found");
60
+ }
61
+ return;
62
+ }
63
+
64
+ if (url.pathname === "/result" && req.method === "POST") {
65
+ const chunks: Buffer[] = [];
66
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
67
+ req.on("end", async () => {
68
+ renderedBlob = Buffer.concat(chunks);
69
+ try {
70
+ await writeFile(outPath, renderedBlob);
71
+ res.writeHead(200, { "content-type": "application/json" });
72
+ res.end(JSON.stringify({ ok: true, size: renderedBlob.length }));
73
+ } catch (err) {
74
+ res.writeHead(500, { "content-type": "application/json" });
75
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
76
+ }
77
+ });
78
+ return;
79
+ }
80
+
81
+ if (url.pathname === "/error" && req.method === "POST") {
82
+ const chunks: Buffer[] = [];
83
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
84
+ req.on("end", () => {
85
+ try {
86
+ const body = JSON.parse(Buffer.concat(chunks).toString());
87
+ renderError = body.message ?? "Unknown render error";
88
+ } catch {
89
+ renderError = Buffer.concat(chunks).toString() || "Unknown render error";
90
+ }
91
+ res.writeHead(200, { "content-type": "application/json" });
92
+ res.end(JSON.stringify({ ok: true }));
93
+ });
94
+ return;
95
+ }
96
+
97
+ if (url.pathname === "/status" && req.method === "GET") {
98
+ res.writeHead(200, { "content-type": "application/json" });
99
+ res.end(JSON.stringify({
100
+ done: renderedBlob !== null || renderError !== null,
101
+ error: renderError,
102
+ hasBlob: renderedBlob !== null,
103
+ blobSize: renderedBlob?.length ?? 0,
104
+ }));
105
+ return;
106
+ }
107
+
108
+ res.writeHead(404);
109
+ res.end("Not found");
110
+ });
111
+
112
+ server.on("error", reject);
113
+ server.listen(0, "127.0.0.1", () => {
114
+ const addr = server.address();
115
+ if (!addr || typeof addr === "string") {
116
+ reject(new Error("Failed to bind callback server"));
117
+ return;
118
+ }
119
+ resolve({ server, port: addr.port });
120
+ });
121
+ });
122
+ }
123
+
124
+ async function waitForResult(port: number, timeoutMs: number): Promise<{ size: number; error: string | null }> {
125
+ const deadline = Date.now() + timeoutMs;
126
+ while (Date.now() < deadline) {
127
+ try {
128
+ const response = await fetch(`http://127.0.0.1:${port}/status`);
129
+ const status = await response.json() as { done: boolean; error: string | null; hasBlob: boolean; blobSize: number };
130
+ if (status.done) {
131
+ if (status.error) return { size: 0, error: status.error };
132
+ return { size: status.blobSize, error: null };
133
+ }
134
+ } catch { /* keep polling */ }
135
+ await new Promise(r => setTimeout(r, 1000));
136
+ }
137
+ return { size: 0, error: `Render timed out after ${timeoutMs / 1000}s` };
138
+ }
139
+
140
+ export async function renderManifest(
141
+ options: RenderOptions,
142
+ config: CliConfig,
143
+ env: Env = process.env,
144
+ ): Promise<RenderResult> {
145
+ const { manifestPath, outPath } = options;
146
+
147
+ // 1. Verify manifest signature
148
+ const verification = await verifyManifestFile(manifestPath, config, env);
149
+ if (!verification.ok) {
150
+ throw new CliError(`Manifest verification failed: ${verification.message}`, 1);
151
+ }
152
+
153
+ // 2. Online revocation check
154
+ const revocation = await checkManifestRevocationOnline(verification.manifest, config, env);
155
+ assertNotRevoked(revocation);
156
+
157
+ // 3. Entitlement check
158
+ const entitlementRequest: RenderEntitlementRequest = {
159
+ ...(options.format ? { format: options.format } : {}),
160
+ ...(options.width ? { width: options.width } : {}),
161
+ ...(options.height ? { height: options.height } : {}),
162
+ };
163
+ const entitlement = checkRenderEntitlement(verification.manifest, entitlementRequest);
164
+ if (!entitlement.ok) throw new CliError(entitlement.message, 1);
165
+
166
+ if (options.verifyOnly) {
167
+ return { ok: true, outPath, size: 0, manifestId: verification.manifest.manifestId };
168
+ }
169
+
170
+ // 4. Resolve headless browser
171
+ let browser: { executablePath: string; source: string };
172
+ try {
173
+ browser = await resolveHeadlessBrowser(env);
174
+ } catch (err) {
175
+ if (err instanceof BrowserNotFoundError) {
176
+ throw new CliError(err.message, 1);
177
+ }
178
+ throw err;
179
+ }
180
+
181
+ // 5. Read manifest JSON
182
+ const manifestJson = await readFile(manifestPath, "utf8");
183
+ const manifest = JSON.parse(manifestJson) as any;
184
+ const canvas = manifest.render.canvas;
185
+
186
+ // 5.5. Cache external assets locally
187
+ let cacheDir = "";
188
+ if (manifest.render.assets && Array.isArray(manifest.render.assets)) {
189
+ try {
190
+ cacheDir = await cacheAssets(manifest.render.assets);
191
+ } catch (err) {
192
+ throw new CliError(`Failed to cache assets: ${err instanceof Error ? err.message : String(err)}`, 1);
193
+ }
194
+ }
195
+
196
+ // 6. Start callback server (serves manifest + receives rendered blob)
197
+ // We need a temp path for the manifest that the server can serve.
198
+ // Instead of writing to a temp file, we'll serve the manifest directly.
199
+ const { server } = await startCallbackServer(outPath);
200
+
201
+ // Patch the server to serve the manifest from memory instead of from outPath
202
+ // Actually, let's use a simpler approach: override the /manifest.json handler
203
+ // by writing the manifest to a known temp location.
204
+ // For now, the server reads from outPath.replace(ext, ".vivi.json") which
205
+ // isn't what we want. Let me fix this properly.
206
+
207
+ // Actually, let's just re-create the server with the manifest in memory.
208
+ server.close();
209
+
210
+ let renderError: string | null = null;
211
+ let hasBlob = false;
212
+ let blobSize = 0;
213
+
214
+ const manifestServer = createServer((req, res) => {
215
+ res.setHeader("Access-Control-Allow-Origin", "*");
216
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
217
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
218
+
219
+ if (req.method === "OPTIONS") {
220
+ res.writeHead(204);
221
+ res.end();
222
+ return;
223
+ }
224
+
225
+ const url = new URL(req.url ?? "/", `http://localhost`);
226
+
227
+ if (cacheDir && url.pathname.startsWith("/assets/") && req.method === "GET") {
228
+ const sha256 = url.pathname.slice(8);
229
+ const cachedPath = join(cacheDir, sha256);
230
+ readFile(cachedPath)
231
+ .then((buffer) => {
232
+ const asset = manifest.render.assets?.find((a: any) => a.sha256 === sha256);
233
+ res.writeHead(200, { "content-type": asset?.mimeType || "application/octet-stream" });
234
+ res.end(buffer);
235
+ })
236
+ .catch(() => {
237
+ res.writeHead(404);
238
+ res.end("Asset not found");
239
+ });
240
+ return;
241
+ }
242
+
243
+ if (url.pathname === "/manifest.json" && req.method === "GET") {
244
+ const manifestObj = { ...manifest };
245
+ if (manifestObj.render?.assets) {
246
+ manifestObj.render.assets = manifestObj.render.assets.map((asset: any) => ({
247
+ ...asset,
248
+ url: `http://127.0.0.1:${serverPort}/assets/${asset.sha256}`,
249
+ }));
250
+ }
251
+ res.writeHead(200, { "content-type": "application/json" });
252
+ res.end(JSON.stringify(manifestObj));
253
+ return;
254
+ }
255
+
256
+ if (url.pathname === "/result" && req.method === "POST") {
257
+ const chunks: Buffer[] = [];
258
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
259
+ req.on("end", async () => {
260
+ const blob = Buffer.concat(chunks);
261
+ try {
262
+ await writeFile(outPath, blob);
263
+ hasBlob = true;
264
+ blobSize = blob.length;
265
+ res.writeHead(200, { "content-type": "application/json" });
266
+ res.end(JSON.stringify({ ok: true, size: blob.length }));
267
+ } catch (err) {
268
+ renderError = err instanceof Error ? err.message : String(err);
269
+ res.writeHead(500, { "content-type": "application/json" });
270
+ res.end(JSON.stringify({ ok: false, error: renderError }));
271
+ }
272
+ });
273
+ return;
274
+ }
275
+
276
+ if (url.pathname === "/error" && req.method === "POST") {
277
+ const chunks: Buffer[] = [];
278
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
279
+ req.on("end", () => {
280
+ let errorMsg = "Unknown render error";
281
+ try {
282
+ const body = JSON.parse(Buffer.concat(chunks).toString());
283
+ errorMsg = body.message ?? errorMsg;
284
+ } catch {
285
+ errorMsg = Buffer.concat(chunks).toString() || errorMsg;
286
+ }
287
+ renderError = errorMsg;
288
+ res.writeHead(200, { "content-type": "application/json" });
289
+ res.end(JSON.stringify({ ok: true, error: errorMsg }));
290
+ });
291
+ return;
292
+ }
293
+
294
+ if (url.pathname === "/status" && req.method === "GET") {
295
+ res.writeHead(200, { "content-type": "application/json" });
296
+ res.end(JSON.stringify({
297
+ done: hasBlob || renderError !== null,
298
+ error: renderError,
299
+ hasBlob,
300
+ blobSize,
301
+ }));
302
+ return;
303
+ }
304
+
305
+ res.writeHead(404);
306
+ res.end("Not found");
307
+ });
308
+
309
+ const serverPort = await new Promise<number>((resolve, reject) => {
310
+ manifestServer.on("error", reject);
311
+ manifestServer.listen(0, "127.0.0.1", () => {
312
+ const addr = manifestServer.address();
313
+ if (!addr || typeof addr === "string") {
314
+ reject(new Error("Failed to bind manifest server"));
315
+ return;
316
+ }
317
+ resolve(addr.port);
318
+ });
319
+ });
320
+
321
+ // 7. Build render URL — points at the host app's /headless-render page
322
+ const renderBaseUrl = env.VIVIPILOT_RENDER_URL ?? DEFAULT_RENDER_URL;
323
+ const params = new URLSearchParams({
324
+ manifest: `http://127.0.0.1:${serverPort}/manifest.json`,
325
+ format: options.format ?? "mp4",
326
+ headless: "true",
327
+ callback: `http://127.0.0.1:${serverPort}/result`,
328
+ });
329
+ if (options.scale) params.set("scale", String(options.scale));
330
+ if (options.transparent) params.set("transparent", "true");
331
+ if (options.fps) params.set("fps", String(options.fps));
332
+
333
+ const fullRenderUrl = `${renderBaseUrl}?${params.toString()}`;
334
+
335
+ // 8. Spawn headless browser
336
+ const args = headlessBrowserArgs(fullRenderUrl, {
337
+ windowSize: { width: Math.max(canvas.width, 1280), height: Math.max(canvas.height, 720) },
338
+ });
339
+
340
+ const child: ChildProcess = spawn(browser.executablePath, args, {
341
+ stdio: ["ignore", "pipe", "pipe"],
342
+ env: { ...env, DISPLAY: env.DISPLAY ?? ":99" },
343
+ });
344
+
345
+ const chromeLogPath = join(process.cwd(), "host", "chrome_browser.log");
346
+ const chromeLogFd = openSync(chromeLogPath, "w");
347
+
348
+ let browserStderr = "";
349
+ let browserStdout = "";
350
+ child.stderr?.on("data", (data: Buffer) => {
351
+ browserStderr += data.toString();
352
+ try { writeSync(chromeLogFd, data); } catch {}
353
+ });
354
+ child.stdout?.on("data", (data: Buffer) => {
355
+ browserStdout += data.toString();
356
+ try { writeSync(chromeLogFd, data); } catch {}
357
+ });
358
+
359
+ // 9. Wait for the blob to arrive at /result, or timeout
360
+ const timeoutMs = parseInt(env.VIVIPILOT_RENDER_TIMEOUT_MS ?? String(DEFAULT_TIMEOUT_MS), 10);
361
+ const result = await waitForResult(serverPort, timeoutMs);
362
+
363
+ // 10. Clean up
364
+ child.kill("SIGTERM");
365
+ setTimeout(() => {
366
+ try { child.kill("SIGKILL"); } catch { /* already dead */ }
367
+ }, 5000);
368
+
369
+ manifestServer.close();
370
+ try { closeSync(chromeLogFd); } catch {}
371
+
372
+ if (result.error) {
373
+ throw new CliError(
374
+ `Render failed: ${result.error}\nBrowser stderr (last 2KB):\n${browserStderr.slice(-2000)}\nBrowser stdout (last 1KB):\n${browserStdout.slice(-1000)}`,
375
+ 1,
376
+ );
377
+ }
378
+
379
+ // 11. Verify output file exists
380
+ try {
381
+ await access(outPath, constants.R_OK);
382
+ const stats = await stat(outPath);
383
+ return { ok: true, outPath, size: stats.size, manifestId: verification.manifest.manifestId };
384
+ } catch {
385
+ throw new CliError(`Render completed but output file not found at ${outPath}`, 1);
386
+ }
387
+ }
388
+
389
+ async function cacheAssets(assets: any[]): Promise<string> {
390
+ const cacheDir = join(
391
+ process.env.HOME || process.env.USERPROFILE || process.cwd(),
392
+ ".cache",
393
+ "vivipilot",
394
+ "assets"
395
+ );
396
+ await mkdir(cacheDir, { recursive: true });
397
+
398
+ for (const asset of assets) {
399
+ if (!asset.sha256 || !asset.url) continue;
400
+ const cachedPath = join(cacheDir, asset.sha256);
401
+
402
+ try {
403
+ await access(cachedPath);
404
+ continue;
405
+ } catch {
406
+ // Asset not in cache, download it
407
+ }
408
+
409
+ const res = await fetch(asset.url);
410
+ if (!res.ok) {
411
+ throw new Error(`Failed to download asset ${asset.url}: HTTP ${res.status}`);
412
+ }
413
+ const buffer = Buffer.from(await res.arrayBuffer());
414
+
415
+ const hash = createHash("sha256").update(buffer).digest("hex");
416
+ if (hash !== asset.sha256) {
417
+ throw new Error(`Asset hash mismatch for ${asset.url}: expected ${asset.sha256}, got ${hash}`);
418
+ }
419
+
420
+ await writeFile(cachedPath, buffer);
421
+ }
422
+
423
+ return cacheDir;
424
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "noImplicitAny": true,
9
+ "strictNullChecks": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "outDir": "./dist",
21
+ "rootDir": "./src",
22
+ "isolatedModules": true
23
+ },
24
+ "include": ["src/**/*"],
25
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
26
+ }