@opul/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 (3) hide show
  1. package/README.md +49 -0
  2. package/dist/index.js +1391 -0
  3. package/package.json +53 -0
package/dist/index.js ADDED
@@ -0,0 +1,1391 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { writeFile, access } from "fs/promises";
8
+ import { join } from "path";
9
+
10
+ // src/ui.ts
11
+ var isTTY = process.stderr.isTTY;
12
+ var c = (code, s) => isTTY ? `\x1B[${code}m${s}\x1B[0m` : s;
13
+ var ink = {
14
+ bold: (s) => c("1", s),
15
+ dim: (s) => c("2", s),
16
+ green: (s) => c("32", s),
17
+ red: (s) => c("31", s),
18
+ yellow: (s) => c("33", s)
19
+ };
20
+ function fmtDuration(ms) {
21
+ const total = Math.round(ms / 1e3);
22
+ const m = Math.floor(total / 60);
23
+ const s = total % 60;
24
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
25
+ }
26
+ function fmtSize(bytes) {
27
+ if (bytes < 1024) return `${bytes} B`;
28
+ const kb = bytes / 1024;
29
+ if (kb < 1024) return `${kb.toFixed(0)} KB`;
30
+ return `${(kb / 1024).toFixed(1)} MB`;
31
+ }
32
+ var Progress = class {
33
+ start = Date.now();
34
+ timer = null;
35
+ line = "";
36
+ constructor() {
37
+ if (isTTY) this.timer = setInterval(() => this.paint(), 250);
38
+ }
39
+ update(index, total, name) {
40
+ const done = index + 1;
41
+ const dots = "\u25CF".repeat(done) + "\u25CB".repeat(Math.max(0, total - done));
42
+ const label = name.length > 24 ? name.slice(0, 23) + "\u2026" : name;
43
+ this.line = `${ink.dim(`step ${done}/${total}`)} ${label.padEnd(24)} ${dots}`;
44
+ this.paint();
45
+ }
46
+ message(text) {
47
+ this.line = ink.dim(text);
48
+ this.paint();
49
+ }
50
+ paint() {
51
+ if (!isTTY) return;
52
+ const elapsed = fmtDuration(Date.now() - this.start);
53
+ process.stderr.write(`\r\x1B[2K${this.line} ${ink.dim(elapsed)}`);
54
+ }
55
+ stop() {
56
+ if (this.timer) clearInterval(this.timer);
57
+ if (isTTY) process.stderr.write("\r\x1B[2K");
58
+ }
59
+ };
60
+
61
+ // src/commands/init.ts
62
+ var STUB = {
63
+ $schema: "https://opul.dev/schema/steps.v1.json",
64
+ name: "My app",
65
+ url: "https://myapp.com",
66
+ viewport: { width: 1440, height: 900 },
67
+ cursor: { color: "#C8F24E" },
68
+ startUrl: "http://localhost:3000",
69
+ steps: [
70
+ { action: "goto", url: "http://localhost:3000/login" },
71
+ { action: "wait", selector: "[data-demo='email']", timeoutMs: 15e3 },
72
+ { action: "type", selector: "[data-demo='email']", text: "demo@myapp.test" },
73
+ { action: "type", selector: "[data-demo='password']", text: "hunter2hunter2" },
74
+ { action: "click", selector: "[data-demo='submit']", name: "Sign in" },
75
+ { action: "wait", selector: "[data-demo='dashboard']" },
76
+ { action: "pause", ms: 900 },
77
+ { action: "click", selector: "[data-demo='billing']", name: "Open billing" },
78
+ { action: "pause", ms: 1400 }
79
+ ]
80
+ };
81
+ async function initCommand(opts) {
82
+ const path = join(process.cwd(), opts.out);
83
+ try {
84
+ await access(path);
85
+ console.log(ink.yellow(`${opts.out} already exists \u2014 leaving it untouched.`));
86
+ return;
87
+ } catch {
88
+ }
89
+ await writeFile(path, JSON.stringify(STUB, null, 2) + "\n");
90
+ console.log(`${ink.green("\u2713")} wrote ${ink.bold(opts.out)}
91
+ `);
92
+ console.log(ink.bold("Add demo hooks to your app so selectors stay stable:"));
93
+ console.log(` ${ink.dim("\u2022")} prefer ${ink.bold("[data-demo='\u2026']")} over brittle CSS/text selectors`);
94
+ console.log(` ${ink.dim("\u2022")} hide Intercom / chat bubbles during the demo`);
95
+ console.log(` ${ink.dim("\u2022")} sign in with a demo account, not real data`);
96
+ console.log(` ${ink.dim("\u2022")} freeze dates/clocks if the UI shows them
97
+ `);
98
+ console.log(`Then record: ${ink.bold("npx opul demo --url http://localhost:3000")}`);
99
+ }
100
+
101
+ // src/commands/doctor.ts
102
+ import { spawnSync } from "child_process";
103
+
104
+ // ../recorder/dist/record.js
105
+ import { mkdir, writeFile as writeFile4, rm } from "fs/promises";
106
+ import { join as join4 } from "path";
107
+ import puppeteer from "puppeteer-core";
108
+
109
+ // ../recorder/dist/chrome.js
110
+ import { existsSync } from "fs";
111
+ import { platform } from "os";
112
+ var CANDIDATES = {
113
+ darwin: [
114
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
115
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
116
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
117
+ ],
118
+ linux: [
119
+ "/usr/bin/google-chrome",
120
+ "/usr/bin/google-chrome-stable",
121
+ "/usr/bin/chromium",
122
+ "/usr/bin/chromium-browser"
123
+ ],
124
+ win32: [
125
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
126
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
127
+ ]
128
+ };
129
+ var ChromeNotFoundError = class extends Error {
130
+ };
131
+ function resolveChromePath(explicit) {
132
+ const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH;
133
+ const ordered = [explicit, fromEnv].filter(Boolean);
134
+ for (const p of ordered) {
135
+ if (existsSync(p))
136
+ return p;
137
+ }
138
+ for (const p of CANDIDATES[platform()] ?? []) {
139
+ if (existsSync(p))
140
+ return p;
141
+ }
142
+ throw new ChromeNotFoundError("Google Chrome not found. Install Chrome, or set PUPPETEER_EXECUTABLE_PATH to its path.");
143
+ }
144
+ function launchArgs() {
145
+ const args = [
146
+ "--hide-scrollbars",
147
+ "--disable-infobars",
148
+ "--disable-notifications",
149
+ "--disable-extensions",
150
+ "--force-color-profile=srgb",
151
+ "--disable-features=Translate,MediaRouter",
152
+ "--no-first-run",
153
+ "--no-default-browser-check"
154
+ ];
155
+ if (platform() === "darwin") {
156
+ args.push("--use-angle=metal", "--enable-gpu");
157
+ }
158
+ return args;
159
+ }
160
+
161
+ // ../recorder/dist/screencast.js
162
+ import { writeFile as writeFile2 } from "fs/promises";
163
+ import { join as join2 } from "path";
164
+ var Screencast = class {
165
+ page;
166
+ framesDir;
167
+ viewport;
168
+ client = null;
169
+ frames = [];
170
+ seq = 0;
171
+ writes = [];
172
+ started = false;
173
+ baseTs = 0;
174
+ lastTs = 0;
175
+ // capture-relative CDP time of the last frame
176
+ lastWall = 0;
177
+ // Date.now() when that frame arrived
178
+ constructor(page, framesDir, viewport) {
179
+ this.page = page;
180
+ this.framesDir = framesDir;
181
+ this.viewport = viewport;
182
+ }
183
+ async start() {
184
+ const client = await this.page.createCDPSession();
185
+ this.client = client;
186
+ client.on("Page.screencastFrame", (evt) => {
187
+ const idx = this.seq++;
188
+ const file2 = String(idx).padStart(6, "0") + ".jpg";
189
+ const ts = evt.metadata?.timestamp ?? 0;
190
+ if (this.frames.length === 0)
191
+ this.baseTs = ts;
192
+ const rel = ts - this.baseTs;
193
+ this.lastTs = rel;
194
+ this.lastWall = Date.now();
195
+ this.frames.push({ file: file2, timestamp: rel });
196
+ const buf = Buffer.from(evt.data, "base64");
197
+ this.writes.push(writeFile2(join2(this.framesDir, file2), buf));
198
+ client.send("Page.screencastFrameAck", { sessionId: evt.sessionId }).catch(() => {
199
+ });
200
+ });
201
+ await client.send("Page.startScreencast", {
202
+ format: "jpeg",
203
+ quality: 92,
204
+ everyNthFrame: 1,
205
+ maxWidth: this.viewport.width,
206
+ maxHeight: this.viewport.height
207
+ });
208
+ this.started = true;
209
+ }
210
+ /**
211
+ * capture-relative seconds now. Projects the CDP clock forward using wall
212
+ * time so static holds (no new paint) still advance — otherwise a final
213
+ * pause would look zero-length to marker/poster logic.
214
+ */
215
+ now() {
216
+ if (this.lastWall === 0)
217
+ return 0;
218
+ return this.lastTs + (Date.now() - this.lastWall) / 1e3;
219
+ }
220
+ async stop() {
221
+ if (!this.started || !this.client)
222
+ return this.frames;
223
+ await this.client.send("Page.stopScreencast").catch(() => {
224
+ });
225
+ await Promise.all(this.writes);
226
+ await this.client.detach().catch(() => {
227
+ });
228
+ this.started = false;
229
+ return this.frames;
230
+ }
231
+ getFrames() {
232
+ return this.frames;
233
+ }
234
+ };
235
+
236
+ // ../recorder/dist/overlay.js
237
+ function overlayInitSource() {
238
+ return function opulInstall(config) {
239
+ const w = window;
240
+ if (w.__opulInstalled)
241
+ return;
242
+ w.__opulInstalled = true;
243
+ const POS_KEY = "__opul_cursor_pos";
244
+ const INK2 = "#0E0E0E";
245
+ const PAPER = "#F4F4F2";
246
+ function ensureFont() {
247
+ if (document.getElementById("__opul_font"))
248
+ return;
249
+ const l = document.createElement("link");
250
+ l.id = "__opul_font";
251
+ l.rel = "stylesheet";
252
+ l.href = "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600&display=swap";
253
+ (document.head || document.documentElement).appendChild(l);
254
+ }
255
+ let root;
256
+ let cursor;
257
+ let lower;
258
+ let pos = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
259
+ try {
260
+ const saved = sessionStorage.getItem(POS_KEY);
261
+ if (saved)
262
+ pos = JSON.parse(saved);
263
+ } catch (e) {
264
+ }
265
+ function savePos() {
266
+ try {
267
+ sessionStorage.setItem(POS_KEY, JSON.stringify(pos));
268
+ } catch (e) {
269
+ }
270
+ }
271
+ function build() {
272
+ ensureFont();
273
+ root = document.createElement("div");
274
+ root.id = "__opul_root";
275
+ root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;font-family:'Plus Jakarta Sans',system-ui,sans-serif;";
276
+ cursor = document.createElement("div");
277
+ cursor.style.cssText = "position:absolute;top:0;left:0;width:26px;height:26px;transform:translate(" + pos.x + "px," + pos.y + "px);will-change:transform;transition:none;";
278
+ cursor.innerHTML = '<svg width="26" height="26" viewBox="0 0 26 26" style="display:block;filter:drop-shadow(0 2px 3px rgba(14,14,14,.35))"><path d="M3 2 L3 21 L8.5 15.8 L12 23 L15 21.6 L11.6 14.6 L19 14.4 Z" fill="' + config.color + '" stroke="' + INK2 + '" stroke-width="1.2" stroke-linejoin="round"/></svg>';
279
+ lower = document.createElement("div");
280
+ lower.style.cssText = "position:absolute;left:24px;bottom:24px;max-width:60%;background:" + INK2 + ";color:" + PAPER + ";font-size:16px;font-weight:600;line-height:1.2;letter-spacing:-0.025em;padding:8px 12px;border-radius:6px;opacity:0;transition:opacity 150ms ease;white-space:nowrap;";
281
+ root.appendChild(lower);
282
+ root.appendChild(cursor);
283
+ (document.body || document.documentElement).appendChild(root);
284
+ }
285
+ function ready() {
286
+ return new Promise((res) => {
287
+ if (document.body) {
288
+ build();
289
+ res();
290
+ } else {
291
+ document.addEventListener("DOMContentLoaded", () => {
292
+ build();
293
+ res();
294
+ });
295
+ }
296
+ });
297
+ }
298
+ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
299
+ function move2(x, y, dur) {
300
+ return new Promise((res) => {
301
+ const sx = pos.x;
302
+ const sy = pos.y;
303
+ const dx = x - sx;
304
+ const dy = y - sy;
305
+ const start = performance.now();
306
+ function frame(now) {
307
+ const t = Math.min(1, (now - start) / dur);
308
+ const e = easeOutCubic(t);
309
+ pos.x = sx + dx * e;
310
+ pos.y = sy + dy * e;
311
+ cursor.style.transform = "translate(" + pos.x + "px," + pos.y + "px)";
312
+ if (t < 1)
313
+ requestAnimationFrame(frame);
314
+ else {
315
+ savePos();
316
+ res();
317
+ }
318
+ }
319
+ requestAnimationFrame(frame);
320
+ });
321
+ }
322
+ function click() {
323
+ return new Promise((res) => {
324
+ cursor.animate([
325
+ { transform: "translate(" + pos.x + "px," + pos.y + "px) scale(1)" },
326
+ {
327
+ transform: "translate(" + pos.x + "px," + pos.y + "px) scale(0.82)"
328
+ },
329
+ { transform: "translate(" + pos.x + "px," + pos.y + "px) scale(1)" }
330
+ ], { duration: 110, easing: "ease-out" });
331
+ const r = document.createElement("div");
332
+ r.style.cssText = "position:absolute;left:" + (pos.x + 4) + "px;top:" + (pos.y + 4) + "px;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;border:2px solid " + config.color + ";opacity:0.9;";
333
+ root.appendChild(r);
334
+ r.animate([
335
+ { transform: "scale(0.4)", opacity: 0.9 },
336
+ { transform: "scale(2.6)", opacity: 0 }
337
+ ], { duration: 400, easing: "ease-out" }).onfinish = () => r.remove();
338
+ setTimeout(res, 120);
339
+ });
340
+ }
341
+ function showLower(text) {
342
+ return new Promise((res) => {
343
+ lower.textContent = text;
344
+ void lower.offsetWidth;
345
+ lower.style.opacity = "1";
346
+ setTimeout(res, 150);
347
+ });
348
+ }
349
+ function hideLower2() {
350
+ return new Promise((res) => {
351
+ lower.style.opacity = "0";
352
+ setTimeout(res, 150);
353
+ });
354
+ }
355
+ function endCard(o) {
356
+ return new Promise((res) => {
357
+ if (cursor)
358
+ cursor.style.display = "none";
359
+ if (lower)
360
+ lower.style.display = "none";
361
+ const card = document.createElement("div");
362
+ card.style.cssText = "position:fixed;inset:0;z-index:2147483646;background:" + INK2 + ";display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;opacity:0;transition:opacity 200ms ease;font-family:'Plus Jakarta Sans',system-ui,sans-serif;";
363
+ const name = document.createElement("div");
364
+ name.textContent = o.name;
365
+ name.style.cssText = "color:" + PAPER + ";font-size:24px;font-weight:600;letter-spacing:-0.025em;";
366
+ card.appendChild(name);
367
+ if (o.url) {
368
+ const url = document.createElement("div");
369
+ url.textContent = o.url;
370
+ url.style.cssText = "color:" + PAPER + ";font-size:16px;font-weight:400;letter-spacing:-0.015em;opacity:0.85;";
371
+ card.appendChild(url);
372
+ }
373
+ if (o.watermark) {
374
+ const wm = document.createElement("div");
375
+ wm.textContent = "Recorded with Opul";
376
+ wm.style.cssText = "color:" + PAPER + ";font-size:13px;font-weight:500;opacity:0.55;margin-top:8px;";
377
+ card.appendChild(wm);
378
+ }
379
+ document.documentElement.appendChild(card);
380
+ void card.offsetWidth;
381
+ card.style.opacity = "1";
382
+ setTimeout(res, o.holdMs);
383
+ });
384
+ }
385
+ w.__opul = { ready, move: move2, click, showLower, hideLower: hideLower2, endCard, pos: () => pos };
386
+ };
387
+ }
388
+
389
+ // ../recorder/dist/runner.js
390
+ var MOVE_MS = 240;
391
+ var PRE_CLICK_MS = 250;
392
+ var SETTLE_MS = 520;
393
+ var CHAR_MS = 55;
394
+ var StepFailure = class extends Error {
395
+ stepIndex;
396
+ selector;
397
+ constructor(message, stepIndex, selector) {
398
+ super(message);
399
+ this.stepIndex = stepIndex;
400
+ this.selector = selector;
401
+ }
402
+ };
403
+ async function installOverlay(page, color) {
404
+ const source = overlayInitSource();
405
+ await page.evaluateOnNewDocument(source, { color });
406
+ await page.evaluate(source, { color }).catch(() => {
407
+ });
408
+ await ensureReady(page);
409
+ }
410
+ async function ensureReady(page) {
411
+ await page.evaluate(async () => {
412
+ const o = window.__opul;
413
+ if (o && o.ready)
414
+ await o.ready();
415
+ });
416
+ }
417
+ async function runSteps(ctx) {
418
+ const { page, cast, steps, onProgress } = ctx;
419
+ const markers = [];
420
+ const total = steps.steps.length;
421
+ for (let i = 0; i < steps.steps.length; i++) {
422
+ const step = steps.steps[i];
423
+ onProgress?.({
424
+ phase: "step",
425
+ stepIndex: i,
426
+ stepTotal: total,
427
+ stepName: step.name ?? step.action
428
+ });
429
+ const startAt = cast.now();
430
+ try {
431
+ await runStep(page, cast, step);
432
+ } catch (err) {
433
+ throw new StepFailure(`step ${i} (${step.action}) failed: ${err.message}`, i, step.selector);
434
+ }
435
+ const endAt = cast.now();
436
+ markers.push({
437
+ kind: step.action === "pause" ? "hold" : "step",
438
+ index: i,
439
+ startAt,
440
+ endAt,
441
+ name: step.name
442
+ });
443
+ }
444
+ return markers;
445
+ }
446
+ async function centerOf(page, selector, timeoutMs) {
447
+ const el = await page.waitForSelector(selector, {
448
+ visible: true,
449
+ timeout: timeoutMs
450
+ });
451
+ if (!el)
452
+ throw new Error(`selector not found: ${selector}`);
453
+ await el.evaluate((n) => n.scrollIntoView({ block: "center", behavior: "smooth" }));
454
+ await sleep(260);
455
+ const rect = await el.evaluate((n) => {
456
+ const r = n.getBoundingClientRect();
457
+ return { x: r.x, y: r.y, w: r.width, h: r.height };
458
+ });
459
+ if (rect.w === 0 && rect.h === 0)
460
+ throw new Error(`selector not visible: ${selector}`);
461
+ return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
462
+ }
463
+ async function runStep(page, cast, step) {
464
+ const timeout = step.timeoutMs ?? 15e3;
465
+ switch (step.action) {
466
+ case "goto": {
467
+ await page.goto(step.url, { waitUntil: "networkidle2", timeout });
468
+ await ensureReady(page);
469
+ return;
470
+ }
471
+ case "wait": {
472
+ await page.waitForSelector(step.selector, {
473
+ visible: true,
474
+ timeout
475
+ });
476
+ return;
477
+ }
478
+ case "pause": {
479
+ await sleep(step.ms ?? 800);
480
+ return;
481
+ }
482
+ case "scroll": {
483
+ const by = step.by ?? Math.round(0.8 * await viewportH(page));
484
+ await page.evaluate((d) => window.scrollBy({ top: d, behavior: "smooth" }), by);
485
+ await sleep(700);
486
+ return;
487
+ }
488
+ case "hover": {
489
+ const c2 = await centerOf(page, step.selector, timeout);
490
+ if (step.name)
491
+ await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
492
+ else
493
+ await move(page, c2.x, c2.y, MOVE_MS);
494
+ await page.mouse.move(c2.x, c2.y);
495
+ await sleep(PRE_CLICK_MS);
496
+ return;
497
+ }
498
+ case "click": {
499
+ const c2 = await centerOf(page, step.selector, timeout);
500
+ await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
501
+ await sleep(PRE_CLICK_MS);
502
+ await overlayClick(page);
503
+ await page.mouse.click(c2.x, c2.y);
504
+ await sleep(SETTLE_MS);
505
+ if (step.name)
506
+ await hideLower(page);
507
+ return;
508
+ }
509
+ case "type": {
510
+ const c2 = await centerOf(page, step.selector, timeout);
511
+ await moveOverlay(page, c2.x, c2.y, MOVE_MS, step.name);
512
+ await sleep(PRE_CLICK_MS);
513
+ await overlayClick(page);
514
+ await page.mouse.click(c2.x, c2.y);
515
+ await sleep(120);
516
+ await page.keyboard.type(step.text, {
517
+ delay: step.charDelayMs ?? CHAR_MS
518
+ });
519
+ await sleep(SETTLE_MS);
520
+ if (step.name)
521
+ await hideLower(page);
522
+ return;
523
+ }
524
+ }
525
+ }
526
+ async function move(page, x, y, dur) {
527
+ await page.evaluate(async (a) => {
528
+ const o = window.__opul;
529
+ if (o)
530
+ await o.move(a.x, a.y, a.dur);
531
+ }, { x, y, dur });
532
+ }
533
+ async function moveOverlay(page, x, y, dur, name) {
534
+ await page.evaluate(async (a) => {
535
+ const o = window.__opul;
536
+ if (!o)
537
+ return;
538
+ if (a.name)
539
+ await o.showLower(a.name);
540
+ await o.move(a.x, a.y, a.dur);
541
+ }, { x, y, dur, name: name ?? "" });
542
+ }
543
+ async function overlayClick(page) {
544
+ await page.evaluate(async () => {
545
+ const o = window.__opul;
546
+ if (o)
547
+ await o.click();
548
+ });
549
+ }
550
+ async function hideLower(page) {
551
+ await page.evaluate(async () => {
552
+ const o = window.__opul;
553
+ if (o)
554
+ await o.hideLower();
555
+ });
556
+ }
557
+ async function viewportH(page) {
558
+ return page.evaluate(() => window.innerHeight);
559
+ }
560
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
561
+
562
+ // ../recorder/dist/encode.js
563
+ import { spawn } from "child_process";
564
+ import { writeFile as writeFile3, stat } from "fs/promises";
565
+ import { join as join3 } from "path";
566
+ var INK = "0x0E0E0E";
567
+ var MIN_D = 1 / 120;
568
+ var MAX_D = 2;
569
+ var FfmpegError = class extends Error {
570
+ };
571
+ var even = (n) => 2 * Math.round(n / 2);
572
+ function targetDims(sourceH) {
573
+ const h = even(sourceH);
574
+ const w = even(h * 16 / 9);
575
+ return { w, h };
576
+ }
577
+ function buildConcatText(frames) {
578
+ const lines = ["ffconcat version 1.0"];
579
+ for (let i = 0; i < frames.length; i++) {
580
+ const f = frames[i];
581
+ lines.push(`file '${f.file}'`);
582
+ const next = frames[i + 1];
583
+ const raw = next ? next.timestamp - f.timestamp : MAX_D;
584
+ const d = Math.min(MAX_D, Math.max(MIN_D, raw));
585
+ lines.push(`duration ${d.toFixed(4)}`);
586
+ }
587
+ const last = frames[frames.length - 1];
588
+ if (last)
589
+ lines.push(`file '${last.file}'`);
590
+ return lines.join("\n") + "\n";
591
+ }
592
+ function concatDurationMs(frames) {
593
+ let s = 0;
594
+ for (let i = 0; i < frames.length; i++) {
595
+ const f = frames[i];
596
+ const next = frames[i + 1];
597
+ const raw = next ? next.timestamp - f.timestamp : MAX_D;
598
+ s += Math.min(MAX_D, Math.max(MIN_D, raw));
599
+ }
600
+ return s * 1e3;
601
+ }
602
+ async function encode(opts) {
603
+ const { w, h } = targetDims(opts.sourceH);
604
+ const concatPath = join3(opts.framesDir, "concat.txt");
605
+ await writeFile3(concatPath, buildConcatText(opts.frames));
606
+ const vf = [
607
+ "fps=60",
608
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease`,
609
+ `pad=${w}:${h}:-1:-1:color=${INK}`,
610
+ "unsharp=3:3:0.3",
611
+ "format=yuv420p"
612
+ ].join(",");
613
+ await run("ffmpeg", [
614
+ "-y",
615
+ "-f",
616
+ "concat",
617
+ "-safe",
618
+ "0",
619
+ "-i",
620
+ concatPath,
621
+ "-vf",
622
+ vf,
623
+ "-r",
624
+ "60",
625
+ "-c:v",
626
+ "libx264",
627
+ "-preset",
628
+ "medium",
629
+ "-crf",
630
+ "21",
631
+ "-pix_fmt",
632
+ "yuv420p",
633
+ "-movflags",
634
+ "+faststart",
635
+ "-an",
636
+ opts.outPath
637
+ ]);
638
+ const { size } = await stat(opts.outPath);
639
+ return { byteSize: size, width: w, height: h };
640
+ }
641
+ function posterFrame(frames, markers) {
642
+ if (frames.length === 0)
643
+ throw new Error("no frames");
644
+ const holds = markers.filter((m) => m.kind === "hold");
645
+ let best = null;
646
+ for (const m of holds) {
647
+ if (!best || m.endAt - m.startAt >= best.endAt - best.startAt)
648
+ best = m;
649
+ }
650
+ if (!best) {
651
+ const steps = markers.filter((m) => m.kind !== "endcard");
652
+ best = steps[steps.length - 1] ?? null;
653
+ }
654
+ const t = best ? (best.startAt + best.endAt) / 2 : frames[frames.length - 1].timestamp * 0.6;
655
+ let chosen = frames[0];
656
+ for (const f of frames) {
657
+ if (f.timestamp <= t)
658
+ chosen = f;
659
+ else
660
+ break;
661
+ }
662
+ return chosen;
663
+ }
664
+ async function writePoster(opts) {
665
+ const { w, h } = targetDims(opts.sourceH);
666
+ const vf = [
667
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease`,
668
+ `pad=${w}:${h}:-1:-1:color=${INK}`
669
+ ].join(",");
670
+ await run("ffmpeg", [
671
+ "-y",
672
+ "-i",
673
+ join3(opts.framesDir, opts.frame.file),
674
+ "-vf",
675
+ vf,
676
+ "-q:v",
677
+ "3",
678
+ opts.outPath
679
+ ]);
680
+ }
681
+ function run(cmd, args) {
682
+ return new Promise((resolve4, reject) => {
683
+ const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
684
+ let err = "";
685
+ p.stderr.on("data", (d) => err += d.toString());
686
+ p.on("error", (e) => reject(new FfmpegError(e.message.includes("ENOENT") ? "ffmpeg not found on PATH. Install it: brew install ffmpeg" : e.message)));
687
+ p.on("close", (code) => {
688
+ if (code === 0)
689
+ resolve4();
690
+ else
691
+ reject(new FfmpegError(`ffmpeg exited ${code}
692
+ ${err.slice(-1500)}`));
693
+ });
694
+ });
695
+ }
696
+
697
+ // ../recorder/dist/record.js
698
+ var END_CARD_MS = 2e3;
699
+ var INITIAL_HOLD_MS = 600;
700
+ async function record(opts) {
701
+ const { steps, outDir } = opts;
702
+ const framesDir = join4(outDir, "frames");
703
+ const videoPath = join4(outDir, "demo.mp4");
704
+ const posterPath = join4(outDir, "poster.jpg");
705
+ const vp = steps.viewport ?? { width: 1440, height: 900 };
706
+ const sourceH = opts.height ?? vp.height;
707
+ const cursorColor = steps.cursor?.color ?? "#C8F24E";
708
+ await mkdir(framesDir, { recursive: true });
709
+ opts.onProgress?.({ phase: "launch", message: "Launching Chrome" });
710
+ const chromePath = resolveChromePath(opts.chromePath);
711
+ const browser = await puppeteer.launch({
712
+ executablePath: chromePath,
713
+ headless: opts.headed ? false : true,
714
+ args: launchArgs(),
715
+ defaultViewport: { width: vp.width, height: vp.height, deviceScaleFactor: 1 },
716
+ userDataDir: opts.userDataDir
717
+ });
718
+ let page = null;
719
+ let screencast = null;
720
+ try {
721
+ page = await browser.newPage();
722
+ if (opts.headed)
723
+ await sleep2(3e3);
724
+ await installOverlay(page, cursorColor);
725
+ const first = steps.steps[0];
726
+ if (!first || first.action !== "goto") {
727
+ const startUrl = steps.startUrl ?? steps.url;
728
+ if (startUrl) {
729
+ await page.goto(startUrl, { waitUntil: "networkidle2" });
730
+ await page.evaluate(async () => {
731
+ const o = window.__opul;
732
+ if (o && o.ready)
733
+ await o.ready();
734
+ });
735
+ }
736
+ }
737
+ screencast = new Screencast(page, framesDir, vp);
738
+ await screencast.start();
739
+ opts.onProgress?.({ phase: "capture", message: "Capturing" });
740
+ await sleep2(INITIAL_HOLD_MS);
741
+ let markers = [];
742
+ try {
743
+ markers = await runSteps({
744
+ page,
745
+ cast: screencast,
746
+ steps,
747
+ onProgress: opts.onProgress
748
+ });
749
+ } catch (err) {
750
+ if (err instanceof StepFailure) {
751
+ await page.screenshot({ path: join4(outDir, "failed.png") }).catch(() => {
752
+ });
753
+ }
754
+ throw err;
755
+ }
756
+ const endStart = screencast.now();
757
+ await page.evaluate(async (o) => {
758
+ const api = window.__opul;
759
+ if (api)
760
+ await api.endCard(o);
761
+ }, {
762
+ name: steps.name,
763
+ url: steps.url ?? "",
764
+ watermark: opts.watermark ?? false,
765
+ holdMs: END_CARD_MS
766
+ });
767
+ markers.push({
768
+ kind: "endcard",
769
+ index: steps.steps.length,
770
+ startAt: endStart,
771
+ endAt: screencast.now()
772
+ });
773
+ const frames = await screencast.stop();
774
+ if (frames.length === 0) {
775
+ throw new Error("no frames captured \u2014 the page may never have painted");
776
+ }
777
+ const meta = {
778
+ width: vp.width,
779
+ height: sourceH,
780
+ startedAt: 0,
781
+ frames,
782
+ markers
783
+ };
784
+ await writeFile4(join4(outDir, "meta.json"), JSON.stringify(meta, null, 2));
785
+ opts.onProgress?.({ phase: "encode", message: "Encoding 60fps" });
786
+ const enc = await encode({ framesDir, frames, sourceH, outPath: videoPath });
787
+ opts.onProgress?.({ phase: "poster", message: "Poster" });
788
+ const pf = posterFrame(frames, markers);
789
+ await writePoster({ framesDir, frame: pf, sourceH, outPath: posterPath });
790
+ await rm(framesDir, { recursive: true, force: true });
791
+ const durationMs = concatDurationMs(frames);
792
+ opts.onProgress?.({ phase: "done" });
793
+ return {
794
+ videoPath,
795
+ posterPath,
796
+ durationMs,
797
+ byteSize: enc.byteSize,
798
+ width: enc.width,
799
+ height: enc.height
800
+ };
801
+ } finally {
802
+ await browser.close().catch(() => {
803
+ });
804
+ }
805
+ }
806
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
807
+
808
+ // ../recorder/dist/codegen.js
809
+ import { writeFile as writeFile5 } from "fs/promises";
810
+ import { createInterface } from "readline";
811
+ import puppeteer2 from "puppeteer-core";
812
+ async function recordSteps(opts) {
813
+ const vp = opts.viewport ?? { width: 1440, height: 900 };
814
+ const chromePath = resolveChromePath(opts.chromePath);
815
+ const browser = await puppeteer2.launch({
816
+ executablePath: chromePath,
817
+ headless: false,
818
+ args: launchArgs(),
819
+ defaultViewport: { width: vp.width, height: vp.height, deviceScaleFactor: 1 }
820
+ });
821
+ const steps = [];
822
+ const page = (await browser.pages())[0] ?? await browser.newPage();
823
+ await page.exposeFunction("__opulEmit", (payload) => {
824
+ let ev;
825
+ try {
826
+ ev = JSON.parse(payload);
827
+ } catch {
828
+ return;
829
+ }
830
+ const last = steps[steps.length - 1];
831
+ if (ev.action === "type" && last && last.action === "click" && last.selector === ev.selector) {
832
+ steps.pop();
833
+ }
834
+ if (ev.action === "click") {
835
+ steps.push({
836
+ action: "click",
837
+ selector: ev.selector,
838
+ name: ev.name || void 0
839
+ });
840
+ } else if (ev.action === "type") {
841
+ steps.push({ action: "type", selector: ev.selector, text: ev.text ?? "" });
842
+ }
843
+ });
844
+ await page.evaluateOnNewDocument(captureSource());
845
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
846
+ await page.evaluate(captureSource()).catch(() => {
847
+ });
848
+ console.error("\n\x1B[1m\u25CF recording\x1B[0m click and type through your app.\n press \x1B[1mEnter\x1B[0m here (or close the window) to save.\n");
849
+ await waitForFinish(browser);
850
+ await browser.close().catch(() => {
851
+ });
852
+ const host = safeHost(opts.url);
853
+ const file2 = {
854
+ $schema: "https://opul.dev/schema/steps.v1.json",
855
+ name: opts.name ?? host,
856
+ url: opts.url,
857
+ startUrl: opts.url,
858
+ viewport: vp,
859
+ steps
860
+ };
861
+ await writeFile5(opts.outPath, JSON.stringify(file2, null, 2) + "\n");
862
+ return file2;
863
+ }
864
+ function waitForFinish(browser) {
865
+ return new Promise((resolve4) => {
866
+ let done = false;
867
+ const finish = () => {
868
+ if (done)
869
+ return;
870
+ done = true;
871
+ rl.close();
872
+ resolve4();
873
+ };
874
+ const rl = createInterface({ input: process.stdin });
875
+ rl.once("line", finish);
876
+ browser.once("disconnected", finish);
877
+ });
878
+ }
879
+ function safeHost(url) {
880
+ try {
881
+ return new URL(url).host || "My app";
882
+ } catch {
883
+ return "My app";
884
+ }
885
+ }
886
+ function captureSource() {
887
+ return function install() {
888
+ const w = window;
889
+ if (w.__opulCap || !w.__opulEmit)
890
+ return;
891
+ w.__opulCap = true;
892
+ const sel = (el) => {
893
+ if (!el || el.nodeType !== 1)
894
+ return null;
895
+ const dd = el.closest("[data-demo]");
896
+ if (dd)
897
+ return `[data-demo='${dd.getAttribute("data-demo")}']`;
898
+ if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1)
899
+ return `#${CSS.escape(el.id)}`;
900
+ const parts = [];
901
+ let node = el;
902
+ while (node && node.nodeType === 1 && parts.length < 4) {
903
+ if (node.id) {
904
+ parts.unshift(`#${CSS.escape(node.id)}`);
905
+ break;
906
+ }
907
+ let part = node.tagName.toLowerCase();
908
+ const parent = node.parentElement;
909
+ if (parent) {
910
+ const same = Array.from(parent.children).filter((c2) => c2.tagName === node.tagName);
911
+ if (same.length > 1)
912
+ part += `:nth-of-type(${same.indexOf(node) + 1})`;
913
+ }
914
+ parts.unshift(part);
915
+ node = node.parentElement;
916
+ }
917
+ return parts.join(" > ");
918
+ };
919
+ const label = (el) => (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
920
+ document.addEventListener("click", (e) => {
921
+ const raw = e.target;
922
+ const t = raw.closest("a,button,[data-demo],[role='button'],[onclick]") || raw;
923
+ const s = sel(t);
924
+ if (s)
925
+ w.__opulEmit(JSON.stringify({ action: "click", selector: s, name: label(t) }));
926
+ }, true);
927
+ document.addEventListener("change", (e) => {
928
+ const t = e.target;
929
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA") && t.value) {
930
+ const s = sel(t);
931
+ if (s)
932
+ w.__opulEmit(JSON.stringify({ action: "type", selector: s, text: t.value }));
933
+ }
934
+ }, true);
935
+ };
936
+ }
937
+
938
+ // ../recorder/dist/schema.js
939
+ var ACTIONS = [
940
+ "goto",
941
+ "wait",
942
+ "click",
943
+ "type",
944
+ "hover",
945
+ "scroll",
946
+ "pause"
947
+ ];
948
+ var StepsValidationError = class extends Error {
949
+ };
950
+ function parseSteps(raw) {
951
+ if (typeof raw !== "object" || raw === null) {
952
+ throw new StepsValidationError("steps file must be a JSON object");
953
+ }
954
+ const obj = raw;
955
+ if (typeof obj.name !== "string" || obj.name.trim() === "") {
956
+ throw new StepsValidationError('"name" is required (product/demo name)');
957
+ }
958
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
959
+ throw new StepsValidationError('"steps" must be a non-empty array');
960
+ }
961
+ const steps = obj.steps.map((s, i) => validateStep(s, i));
962
+ const vp = obj.viewport;
963
+ const viewport = vp && typeof vp.width === "number" && typeof vp.height === "number" ? { width: vp.width, height: vp.height } : { width: 1440, height: 900 };
964
+ return {
965
+ $schema: typeof obj.$schema === "string" ? obj.$schema : void 0,
966
+ name: obj.name,
967
+ url: typeof obj.url === "string" ? obj.url : void 0,
968
+ startUrl: typeof obj.startUrl === "string" ? obj.startUrl : void 0,
969
+ viewport,
970
+ cursor: obj.cursor && typeof obj.cursor.color === "string" ? { color: obj.cursor.color } : void 0,
971
+ steps
972
+ };
973
+ }
974
+ function validateStep(s, i) {
975
+ if (typeof s !== "object" || s === null) {
976
+ throw new StepsValidationError(`step ${i} must be an object`);
977
+ }
978
+ const step = s;
979
+ const action = step.action;
980
+ if (typeof action !== "string" || !ACTIONS.includes(action)) {
981
+ throw new StepsValidationError(`step ${i}: action must be one of ${ACTIONS.join(", ")}`);
982
+ }
983
+ const a = action;
984
+ const need = (field) => {
985
+ if (step[field] == null || step[field] === "") {
986
+ throw new StepsValidationError(`step ${i} (${a}): "${field}" is required`);
987
+ }
988
+ };
989
+ if (a === "goto")
990
+ need("url");
991
+ if (a === "click" || a === "hover" || a === "wait")
992
+ need("selector");
993
+ if (a === "type") {
994
+ need("selector");
995
+ need("text");
996
+ }
997
+ if (a === "pause" && typeof step.ms !== "number") {
998
+ throw new StepsValidationError(`step ${i} (pause): "ms" number is required`);
999
+ }
1000
+ return {
1001
+ action: a,
1002
+ selector: typeof step.selector === "string" ? step.selector : void 0,
1003
+ url: typeof step.url === "string" ? step.url : void 0,
1004
+ text: typeof step.text === "string" ? step.text : void 0,
1005
+ ms: typeof step.ms === "number" ? step.ms : void 0,
1006
+ timeoutMs: typeof step.timeoutMs === "number" ? step.timeoutMs : 15e3,
1007
+ name: typeof step.name === "string" ? step.name : void 0,
1008
+ by: typeof step.by === "number" ? step.by : void 0,
1009
+ charDelayMs: typeof step.charDelayMs === "number" ? step.charDelayMs : void 0
1010
+ };
1011
+ }
1012
+
1013
+ // src/commands/doctor.ts
1014
+ async function doctorCommand() {
1015
+ let ok = true;
1016
+ try {
1017
+ const p = resolveChromePath();
1018
+ console.log(`${ink.green("\u2713")} Chrome ${ink.dim(p)}`);
1019
+ } catch {
1020
+ ok = false;
1021
+ console.log(
1022
+ `${ink.red("\u2717")} Chrome not found \u2014 install Google Chrome, or set ${ink.bold("PUPPETEER_EXECUTABLE_PATH")}`
1023
+ );
1024
+ }
1025
+ const ff = spawnSync("ffmpeg", ["-version"], { encoding: "utf8" });
1026
+ if (ff.status === 0) {
1027
+ const v = (ff.stdout || "").split("\n")[0] ?? "";
1028
+ console.log(`${ink.green("\u2713")} ffmpeg ${ink.dim(v)}`);
1029
+ } else {
1030
+ ok = false;
1031
+ console.log(
1032
+ `${ink.red("\u2717")} ffmpeg not found \u2014 install it: ${ink.bold("brew install ffmpeg")} (macOS) \xB7 ${ink.bold("apt install ffmpeg")} (Linux)`
1033
+ );
1034
+ }
1035
+ console.log(`${ink.green("\u2713")} Node ${ink.dim(process.version)}`);
1036
+ console.log(
1037
+ ok ? `
1038
+ ${ink.green("Ready to record.")} Try: ${ink.bold("opul record --url http://localhost:3000")}` : `
1039
+ ${ink.yellow("Install the missing tools above, then re-run")} ${ink.bold("opul doctor")}.`
1040
+ );
1041
+ if (!ok) process.exit(1);
1042
+ }
1043
+
1044
+ // src/commands/record.ts
1045
+ import { resolve, relative } from "path";
1046
+ async function recordCommand(opts) {
1047
+ if (!opts.url) {
1048
+ console.error(
1049
+ `${ink.red("\u2717")} --url is required. e.g. ${ink.bold("opul record --url http://localhost:3000")}`
1050
+ );
1051
+ process.exit(1);
1052
+ }
1053
+ const outPath = resolve(process.cwd(), opts.out);
1054
+ try {
1055
+ const file2 = await recordSteps({
1056
+ url: opts.url,
1057
+ outPath,
1058
+ name: opts.name,
1059
+ chromePath: opts.chrome
1060
+ });
1061
+ const rel = relative(process.cwd(), outPath) || opts.out;
1062
+ const n = file2.steps.length;
1063
+ console.log(
1064
+ `${ink.green("\u2713")} wrote ${ink.bold(rel)} ${ink.dim(`(${n} step${n === 1 ? "" : "s"})`)}`
1065
+ );
1066
+ if (n === 0) {
1067
+ console.log(
1068
+ ink.yellow(
1069
+ " no steps captured \u2014 click/type inside the window next time before saving."
1070
+ )
1071
+ );
1072
+ }
1073
+ console.log(
1074
+ `
1075
+ review it, then record the video:
1076
+ ${ink.bold(`opul demo --url ${opts.url} --steps ${rel}`)}`
1077
+ );
1078
+ } catch (e) {
1079
+ if (e instanceof ChromeNotFoundError) {
1080
+ console.error(`${ink.red("\u2717")} ${e.message}`);
1081
+ } else {
1082
+ console.error(`${ink.red("\u2717")} ${e.message}`);
1083
+ }
1084
+ process.exit(1);
1085
+ }
1086
+ }
1087
+
1088
+ // src/commands/demo.ts
1089
+ import { readFile, mkdir as mkdir2, writeFile as writeFile6 } from "fs/promises";
1090
+ import { join as join5, resolve as resolve2, relative as relative2 } from "path";
1091
+ async function demoCommand(opts) {
1092
+ const stepsPath = resolve2(process.cwd(), opts.steps);
1093
+ const outDir = resolve2(process.cwd(), opts.out);
1094
+ let raw;
1095
+ try {
1096
+ raw = await readFile(stepsPath, "utf8");
1097
+ } catch {
1098
+ fail(
1099
+ `no steps file at ${ink.bold(opts.steps)}
1100
+ run ${ink.bold("opul init")} to create one.`
1101
+ );
1102
+ return;
1103
+ }
1104
+ let steps;
1105
+ try {
1106
+ steps = parseSteps(JSON.parse(raw));
1107
+ } catch (e) {
1108
+ if (e instanceof StepsValidationError) fail(`invalid steps: ${e.message}`);
1109
+ else fail(`steps file is not valid JSON: ${e.message}`);
1110
+ return;
1111
+ }
1112
+ if (opts.url) steps.startUrl = opts.url;
1113
+ if (opts.profile) {
1114
+ console.error(
1115
+ ink.yellow(
1116
+ "\u26A0 using your Chrome profile \u2014 real cookies/logins may appear on screen. A fresh profile is safer."
1117
+ )
1118
+ );
1119
+ }
1120
+ await mkdir2(outDir, { recursive: true });
1121
+ const progress = new Progress();
1122
+ try {
1123
+ const result = await record({
1124
+ steps,
1125
+ outDir,
1126
+ headed: opts.headed,
1127
+ chromePath: opts.chrome,
1128
+ userDataDir: opts.profile,
1129
+ height: opts.height ? Number(opts.height) : void 0,
1130
+ watermark: true,
1131
+ // free tier: end-card badge on local records
1132
+ onProgress: (p) => {
1133
+ if (p.phase === "step" && p.stepIndex != null && p.stepTotal != null) {
1134
+ progress.update(p.stepIndex, p.stepTotal, p.stepName ?? "");
1135
+ } else if (p.message) {
1136
+ progress.message(p.message + "\u2026");
1137
+ }
1138
+ }
1139
+ });
1140
+ progress.stop();
1141
+ await writeFile6(
1142
+ join5(outDir, "demo.json"),
1143
+ JSON.stringify(
1144
+ {
1145
+ title: steps.name,
1146
+ durationMs: Math.round(result.durationMs),
1147
+ width: result.width,
1148
+ height: result.height,
1149
+ byteSize: result.byteSize
1150
+ },
1151
+ null,
1152
+ 2
1153
+ )
1154
+ );
1155
+ const rel = (p) => relative2(process.cwd(), p) || p;
1156
+ console.log(`${ink.green("\u2713")} ${ink.bold("demo recorded")}`);
1157
+ console.log(` ${rel(result.videoPath)} ${ink.dim(
1158
+ `${fmtDuration(result.durationMs)} \xB7 ${fmtSize(result.byteSize)} \xB7 ${result.width}\xD7${result.height} \xB7 60fps`
1159
+ )}`);
1160
+ console.log(` ${rel(result.posterPath)}`);
1161
+ console.log(`
1162
+ next: ${ink.bold(`opul publish ${rel(result.videoPath)}`)}`);
1163
+ } catch (e) {
1164
+ progress.stop();
1165
+ if (e instanceof ChromeNotFoundError) fail(e.message);
1166
+ else if (e instanceof FfmpegError) fail(e.message);
1167
+ else if (e instanceof StepFailure) {
1168
+ fail(
1169
+ `${e.message}
1170
+ selector: ${ink.bold(e.selector ?? "\u2014")}
1171
+ saved ${ink.bold(join5(opts.out, "failed.png"))} showing the page when it failed.`
1172
+ );
1173
+ } else fail(e.message);
1174
+ }
1175
+ }
1176
+ function fail(msg) {
1177
+ console.error(`${ink.red("\u2717")} ${msg}`);
1178
+ process.exit(1);
1179
+ }
1180
+
1181
+ // src/commands/auth.ts
1182
+ import { createInterface as createInterface2 } from "readline/promises";
1183
+ import { stdin, stdout } from "process";
1184
+
1185
+ // src/config.ts
1186
+ import { readFile as readFile2, writeFile as writeFile7, mkdir as mkdir3 } from "fs/promises";
1187
+ import { homedir } from "os";
1188
+ import { join as join6 } from "path";
1189
+ var dir = join6(homedir(), ".opul");
1190
+ var file = join6(dir, "config.json");
1191
+ var API_BASE = () => process.env.OPUL_API || "https://opul.dev";
1192
+ async function loadConfig() {
1193
+ try {
1194
+ return JSON.parse(await readFile2(file, "utf8"));
1195
+ } catch {
1196
+ return {};
1197
+ }
1198
+ }
1199
+ async function saveConfig(cfg) {
1200
+ await mkdir3(dir, { recursive: true });
1201
+ await writeFile7(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
1202
+ }
1203
+ async function requireToken() {
1204
+ const { token } = await loadConfig();
1205
+ if (!token) {
1206
+ throw new Error("not logged in \u2014 run `opul login` first.");
1207
+ }
1208
+ return token;
1209
+ }
1210
+
1211
+ // src/commands/auth.ts
1212
+ async function loginCommand() {
1213
+ const base = API_BASE();
1214
+ console.log(`Open ${ink.bold(`${base}/cli`)} and copy your device token.
1215
+ `);
1216
+ const rl = createInterface2({ input: stdin, output: stdout });
1217
+ const token = (await rl.question("Paste token: ")).trim();
1218
+ rl.close();
1219
+ if (!token) return fail2("no token entered.");
1220
+ const res = await fetch(`${base}/api/me`, {
1221
+ headers: { authorization: `Bearer ${token}` }
1222
+ }).catch(() => null);
1223
+ if (!res || !res.ok) return fail2("token rejected \u2014 check it and try again.");
1224
+ const me = await res.json();
1225
+ const cfg = await loadConfig();
1226
+ await saveConfig({ ...cfg, token, apiBase: base });
1227
+ console.log(
1228
+ `${ink.green("\u2713")} logged in as ${ink.bold(me.email ?? "you")} ${ink.dim(`(${me.plan ?? "free"})`)}`
1229
+ );
1230
+ }
1231
+ async function logoutCommand() {
1232
+ const cfg = await loadConfig();
1233
+ delete cfg.token;
1234
+ await saveConfig(cfg);
1235
+ console.log(`${ink.green("\u2713")} logged out.`);
1236
+ }
1237
+ async function whoamiCommand() {
1238
+ const { token } = await loadConfig();
1239
+ if (!token) return fail2("not logged in \u2014 run `opul login`.");
1240
+ const res = await fetch(`${API_BASE()}/api/me`, {
1241
+ headers: { authorization: `Bearer ${token}` }
1242
+ }).catch(() => null);
1243
+ if (!res || !res.ok) return fail2("session expired \u2014 run `opul login` again.");
1244
+ const me = await res.json();
1245
+ console.log(`${ink.bold(me.email ?? "you")} ${ink.dim(`\xB7 ${me.plan ?? "free"}`)}`);
1246
+ }
1247
+ function fail2(msg) {
1248
+ console.error(`${ink.red("\u2717")} ${msg}`);
1249
+ process.exit(1);
1250
+ }
1251
+
1252
+ // src/commands/publish.ts
1253
+ import { readFile as readFile3, stat as stat2 } from "fs/promises";
1254
+ import { basename, resolve as resolve3, dirname, join as join7 } from "path";
1255
+ async function publishCommand(file2, opts) {
1256
+ const base = API_BASE();
1257
+ let token;
1258
+ try {
1259
+ token = await requireToken();
1260
+ } catch (e) {
1261
+ return fail3(e.message);
1262
+ }
1263
+ const videoPath = resolve3(process.cwd(), file2);
1264
+ const posterPath = opts.poster ? resolve3(process.cwd(), opts.poster) : join7(dirname(videoPath), "poster.jpg");
1265
+ let video;
1266
+ try {
1267
+ video = await readFile3(videoPath);
1268
+ } catch {
1269
+ return fail3(`file not found: ${file2}`);
1270
+ }
1271
+ const poster = await readFile3(posterPath).catch(() => null);
1272
+ const size = (await stat2(videoPath)).size;
1273
+ if (size > 200 * 1024 * 1024) return fail3("file exceeds 200 MB upload cap.");
1274
+ const meta = await readFile3(join7(dirname(videoPath), "demo.json"), "utf8").then((s) => JSON.parse(s)).catch(() => ({}));
1275
+ const title = opts.title ?? meta.title ?? basename(videoPath).replace(/\.[^.]+$/, "");
1276
+ const auth = { authorization: `Bearer ${token}` };
1277
+ const endpoint = opts.replace ? `${base}/api/demos/${encodeURIComponent(opts.replace)}/replace` : `${base}/api/demos`;
1278
+ const presign = await fetch(endpoint, {
1279
+ method: "POST",
1280
+ headers: { ...auth, "content-type": "application/json" },
1281
+ body: JSON.stringify({ title, hasPoster: !!poster, byteSize: size })
1282
+ }).catch(() => null);
1283
+ if (!presign || !presign.ok) {
1284
+ return fail3(
1285
+ presign ? `publish failed (${presign.status}): ${await presign.text()}` : "could not reach opul.dev \u2014 check your connection."
1286
+ );
1287
+ }
1288
+ const { id, slug, uploadUrl, posterUploadUrl } = await presign.json();
1289
+ process.stderr.write(ink.dim(`uploading ${fmtSize(size)}\u2026`));
1290
+ const put = await fetch(uploadUrl, {
1291
+ method: "PUT",
1292
+ headers: { "content-type": "video/mp4" },
1293
+ body: video
1294
+ }).catch(() => null);
1295
+ if (!put || !put.ok) return fail3("\nupload failed.");
1296
+ if (poster && posterUploadUrl) {
1297
+ await fetch(posterUploadUrl, {
1298
+ method: "PUT",
1299
+ headers: { "content-type": "image/jpeg" },
1300
+ body: poster
1301
+ }).catch(() => null);
1302
+ }
1303
+ process.stderr.write("\r\x1B[2K");
1304
+ const done = await fetch(`${base}/api/demos/${id}/complete`, {
1305
+ method: "POST",
1306
+ headers: { ...auth, "content-type": "application/json" },
1307
+ body: JSON.stringify({
1308
+ durationMs: meta.durationMs,
1309
+ width: meta.width,
1310
+ height: meta.height
1311
+ })
1312
+ }).catch(() => null);
1313
+ if (!done || !done.ok) return fail3("failed to finalize the demo.");
1314
+ console.log(`${ink.green("\u2713")} published`);
1315
+ console.log(` ${ink.bold(`${base}/d/${slug}`)}`);
1316
+ }
1317
+ function fail3(msg) {
1318
+ console.error(`${ink.red("\u2717")} ${msg}`);
1319
+ process.exit(1);
1320
+ }
1321
+
1322
+ // src/commands/manage.ts
1323
+ async function lsCommand() {
1324
+ const base = API_BASE();
1325
+ let token;
1326
+ try {
1327
+ token = await requireToken();
1328
+ } catch (e) {
1329
+ return fail4(e.message);
1330
+ }
1331
+ const res = await fetch(`${base}/api/demos`, {
1332
+ headers: { authorization: `Bearer ${token}` }
1333
+ }).catch(() => null);
1334
+ if (!res || !res.ok) return fail4("could not reach opul.dev \u2014 check your login.");
1335
+ const { demos } = await res.json();
1336
+ if (demos.length === 0) {
1337
+ console.log(ink.dim("no demos yet \u2014 record one and `opul publish` it."));
1338
+ return;
1339
+ }
1340
+ for (const d of demos) {
1341
+ const meta = [
1342
+ d.status,
1343
+ fmtDuration(d.durationMs),
1344
+ fmtSize(d.byteSize),
1345
+ `${d.viewCount} views`
1346
+ ].filter(Boolean).join(" \xB7 ");
1347
+ console.log(`${ink.bold(d.title)} ${ink.dim(d.slug)}`);
1348
+ console.log(` ${d.url}`);
1349
+ console.log(` ${ink.dim(meta)}
1350
+ `);
1351
+ }
1352
+ }
1353
+ async function rmCommand(slug) {
1354
+ const base = API_BASE();
1355
+ let token;
1356
+ try {
1357
+ token = await requireToken();
1358
+ } catch (e) {
1359
+ return fail4(e.message);
1360
+ }
1361
+ const res = await fetch(`${base}/api/demos/${encodeURIComponent(slug)}`, {
1362
+ method: "DELETE",
1363
+ headers: { authorization: `Bearer ${token}` }
1364
+ }).catch(() => null);
1365
+ if (!res) return fail4("could not reach opul.dev.");
1366
+ if (res.status === 404) return fail4(`no demo with slug "${slug}".`);
1367
+ if (!res.ok) return fail4(`delete failed (${res.status}).`);
1368
+ console.log(`${ink.green("\u2713")} deleted ${ink.bold(slug)}`);
1369
+ }
1370
+ function fail4(msg) {
1371
+ console.error(`${ink.red("\u2717")} ${msg}`);
1372
+ process.exit(1);
1373
+ }
1374
+
1375
+ // src/index.ts
1376
+ var program = new Command();
1377
+ program.name("opul").description("Record a product demo from your running web app.").version("0.1.0");
1378
+ program.command("init").description("Create an opul.steps.json stub").option("--out <file>", "output path", "opul.steps.json").action(initCommand);
1379
+ program.command("doctor").description("Check that Chrome and ffmpeg are installed").action(doctorCommand);
1380
+ program.command("record").description("Click through your app to generate a steps file").option("--url <url>", "app URL to open").option("--out <file>", "steps file to write", "opul.steps.json").option("--name <name>", "product/demo name").option("--chrome <path>", "path to Chrome executable").action(recordCommand);
1381
+ program.command("demo").description("Record a demo MP4 from your app").option("--url <url>", "app URL to open first (overrides startUrl)").option("--steps <file>", "steps file", "opul.steps.json").option("--out <dir>", "output directory", "opul-out").option("--headed", "run Chrome headed with a 3s pre-roll").option("--height <px>", "output height (width derived to 16:9)").option("--profile <dir>", "use an existing Chrome profile (prints a warning)").option("--chrome <path>", "path to Chrome executable").action(demoCommand);
1382
+ program.command("publish <file>").description("Publish an MP4 and get a shareable link").option("--title <title>", "demo title").option("--replace <slug>", "replace an existing demo, keeping its URL").option("--poster <file>", "poster image (defaults to ./poster.jpg)").action(publishCommand);
1383
+ program.command("ls").description("List your published demos").action(lsCommand);
1384
+ program.command("rm <slug>").description("Delete a published demo").action(rmCommand);
1385
+ program.command("login").description("Log in with GitHub").action(loginCommand);
1386
+ program.command("logout").description("Log out").action(logoutCommand);
1387
+ program.command("whoami").description("Show the logged-in account").action(whoamiCommand);
1388
+ program.parseAsync(process.argv).catch((e) => {
1389
+ console.error(e);
1390
+ process.exit(1);
1391
+ });