@spark-apps/quickpeek 1.2.0 → 1.2.2

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.
@@ -43,7 +43,7 @@ function getErrorMessage(err) {
43
43
  return err instanceof Error ? err.message : String(err);
44
44
  }
45
45
  function toTitleCase(text) {
46
- return text.replace(/\w+/g, (word, index) => {
46
+ return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
47
47
  if (index === 0) {
48
48
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
49
49
  }
@@ -107,6 +107,23 @@ var init_utils = __esm({
107
107
  });
108
108
 
109
109
  // src/lib/api-client.ts
110
+ function apiKey() {
111
+ return process.env.SPARK_AI_KEY || "";
112
+ }
113
+ function hostOf2(url) {
114
+ try {
115
+ return new URL(url).host;
116
+ } catch {
117
+ return "AI service";
118
+ }
119
+ }
120
+ function firstLine(text) {
121
+ return truncate((text ?? "").trim().split("\n")[0] ?? "", 200);
122
+ }
123
+ function truncate(text, max) {
124
+ const trimmed = (text ?? "").trim();
125
+ return trimmed.length > max ? `${trimmed.slice(0, max)}\u2026` : trimmed;
126
+ }
110
127
  async function getOrRefreshDirectToken() {
111
128
  const now = Date.now();
112
129
  if (cachedDirectToken && cachedDirectToken.expiresAt > now + 3e5) {
@@ -115,7 +132,7 @@ async function getOrRefreshDirectToken() {
115
132
  try {
116
133
  const response = await fetch(SPARK_AI_TOKEN, {
117
134
  method: "POST",
118
- headers: { "X-Api-Key": API_KEY }
135
+ headers: { "X-Api-Key": apiKey() }
119
136
  });
120
137
  if (!response.ok) {
121
138
  throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`);
@@ -158,8 +175,10 @@ async function getOrRefreshRelayToken(email) {
158
175
  throw new Error(`Failed to get QuickPeek token: ${getErrorMessage(err)}`);
159
176
  }
160
177
  }
161
- async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier) {
162
- let lastRateLimitError = null;
178
+ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
179
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
180
+ let lastRetriableError = null;
181
+ const host = hostOf2(chatUrl);
163
182
  for (let attempt = 1; attempt <= 3; attempt++) {
164
183
  try {
165
184
  const response = await fetch(chatUrl, {
@@ -175,36 +194,53 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
175
194
  ]
176
195
  })
177
196
  });
197
+ const bodyText = await response.text().catch(() => "");
178
198
  let data = {};
179
- const contentType = response.headers.get("content-type") ?? "";
180
- if (contentType.includes("application/json")) {
181
- data = await response.json();
199
+ try {
200
+ data = bodyText ? JSON.parse(bodyText) : {};
201
+ } catch {
182
202
  }
183
203
  if (!response.ok) {
184
- const error = data.error || `Spark AI error: ${response.status} ${response.statusText}`;
185
- if (response.status === 429 && attempt < 3) {
186
- const retryAfter = response.headers.get("Retry-After");
187
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : 1e3 * 2 ** (attempt - 1);
188
- lastRateLimitError = {
204
+ const upstream = data.error || firstLine(bodyText) || response.statusText || "no error body";
205
+ const error = `${host} ${response.status}: ${upstream}`;
206
+ const detail = truncate(bodyText, 800);
207
+ const isRetriable = response.status === 429 || response.status >= 500;
208
+ if (isRetriable && attempt < 3) {
209
+ const retryAfter2 = response.headers.get("Retry-After");
210
+ const delayMs = retryAfter2 ? parseInt(retryAfter2, 10) * 1e3 : response.status >= 500 ? backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 2e3 : 1e3 * 2 ** (attempt - 1);
211
+ onRetry?.({ attempt, status: response.status, delayMs, host });
212
+ lastRetriableError = {
189
213
  success: false,
190
214
  error,
191
- isRateLimit: true,
192
- ...retryAfter ? { retryAfterSeconds: parseInt(retryAfter, 10) } : {}
215
+ isRateLimit: response.status === 429,
216
+ status: response.status,
217
+ host,
218
+ ...detail ? { detail } : {},
219
+ ...retryAfter2 ? { retryAfterSeconds: parseInt(retryAfter2, 10) } : {}
193
220
  };
194
221
  await new Promise((resolve10) => setTimeout(resolve10, Math.min(delayMs, 3e4)));
195
222
  continue;
196
223
  }
224
+ const retryAfter = response.headers.get("Retry-After");
225
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
197
226
  return {
198
227
  success: false,
199
228
  error,
200
229
  isRateLimit: response.status === 429,
201
- isAuthError: response.status === 401 || response.status === 403
230
+ isAuthError: response.status === 401 || response.status === 403,
231
+ status: response.status,
232
+ host,
233
+ ...detail ? { detail } : {},
234
+ ...Number.isFinite(retryAfterSeconds) ? { retryAfterSeconds } : {}
202
235
  };
203
236
  }
204
237
  if (!data.success || !data.response) {
205
238
  return {
206
239
  success: false,
207
- error: data.error || "No response from Spark AI"
240
+ error: `${host} 200 but no content: ${data.error || firstLine(bodyText) || "empty response"}`,
241
+ status: response.status,
242
+ host,
243
+ ...bodyText ? { detail: truncate(bodyText, 800) } : {}
208
244
  };
209
245
  }
210
246
  return {
@@ -218,15 +254,17 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier)
218
254
  }
219
255
  };
220
256
  } catch (err) {
221
- if (attempt === 3) throw err;
257
+ if (attempt === 3) {
258
+ return { success: false, error: `${host} unreachable: ${getErrorMessage(err)}`, host };
259
+ }
222
260
  const delayMs = 1e3 * 2 ** (attempt - 1);
223
261
  await new Promise((resolve10) => setTimeout(resolve10, delayMs));
224
262
  }
225
263
  }
226
- return lastRateLimitError || { success: false, error: "Request failed after retries" };
264
+ return lastRetriableError || { success: false, error: `${host}: request failed after retries`, host };
227
265
  }
228
- async function callAI(systemPrompt, userPrompt, tier = "free") {
229
- if (!API_KEY) {
266
+ async function callAI(systemPrompt, userPrompt, tier = "free", opts = {}) {
267
+ if (!apiKey()) {
230
268
  return {
231
269
  success: false,
232
270
  error: "AI service is not configured. Please report this issue.",
@@ -235,12 +273,12 @@ async function callAI(systemPrompt, userPrompt, tier = "free") {
235
273
  }
236
274
  try {
237
275
  const token = await getOrRefreshDirectToken();
238
- return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier);
276
+ return await executeChat(SPARK_AI_CHAT, token, systemPrompt, userPrompt, tier, opts);
239
277
  } catch (err) {
240
278
  return { success: false, error: `Spark AI error: ${getErrorMessage(err)}` };
241
279
  }
242
280
  }
243
- async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
281
+ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free", opts = {}) {
244
282
  if (!email) {
245
283
  return {
246
284
  success: false,
@@ -250,11 +288,11 @@ async function callAIViaRelay(systemPrompt, userPrompt, email, tier = "free") {
250
288
  }
251
289
  try {
252
290
  let token = await getOrRefreshRelayToken(email);
253
- let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
291
+ let result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
254
292
  if (!result.success && result.isAuthError) {
255
293
  cachedRelayToken = null;
256
294
  token = await getOrRefreshRelayToken(email);
257
- result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier);
295
+ result = await executeChat(RELAY_CHAT_URL, token, systemPrompt, userPrompt, tier, opts);
258
296
  }
259
297
  return result;
260
298
  } catch (err) {
@@ -286,18 +324,19 @@ function normalizeUrl(input) {
286
324
  url = url.replace(/^(https?:\/\/)?(www\.)?/i, "");
287
325
  return `https://${url}`;
288
326
  }
289
- var SPARK_AI_BASE, SPARK_AI_CHAT, SPARK_AI_TOKEN, RELAY_BASE, RELAY_TOKEN_URL, RELAY_CHAT_URL, API_KEY, cachedDirectToken, cachedRelayToken;
327
+ var SERVER_BACKOFF_MS, CLI_BACKOFF_MS, SPARK_AI_BASE, SPARK_AI_CHAT, SPARK_AI_TOKEN, RELAY_BASE, RELAY_TOKEN_URL, RELAY_CHAT_URL, cachedDirectToken, cachedRelayToken;
290
328
  var init_api_client = __esm({
291
329
  "src/lib/api-client.ts"() {
292
330
  "use strict";
293
331
  init_utils();
332
+ SERVER_BACKOFF_MS = [2e3, 4e3];
333
+ CLI_BACKOFF_MS = [1e4, 3e4];
294
334
  SPARK_AI_BASE = "https://sparkbrain.app";
295
335
  SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
296
336
  SPARK_AI_TOKEN = `${SPARK_AI_BASE}/api/models/token`;
297
337
  RELAY_BASE = process.env.QUICKPEEK_API_BASE || "https://quickpeek.co";
298
338
  RELAY_TOKEN_URL = `${RELAY_BASE}/api/cli/token`;
299
339
  RELAY_CHAT_URL = `${RELAY_BASE}/api/cli/ai`;
300
- API_KEY = process.env.SPARK_AI_KEY || "";
301
340
  cachedDirectToken = null;
302
341
  cachedRelayToken = null;
303
342
  }
@@ -322,7 +361,7 @@ async function getStatusCached(email) {
322
361
  function getTier(d) {
323
362
  if (!d.subscription || !d.registered) return "free";
324
363
  if (d.subscription.status === "canceled" || d.subscription.status === "past_due") return "free";
325
- return TIERS[d.subscription.price_id] ?? "free";
364
+ return TIERS[d.subscription.price_id] ?? "pro";
326
365
  }
327
366
  async function getTierByEmail(email) {
328
367
  try {
@@ -339,17 +378,18 @@ async function isVerified(email) {
339
378
  }
340
379
  }
341
380
  function pricingUrl(opts) {
342
- const u = new URL(`${BASE}/pricing/${APP_ID}`);
381
+ const u = new URL(`${BASE}/${OWNER_SLUG}/${APP_ID}`);
343
382
  if (opts?.email) u.searchParams.set("email", opts.email);
344
383
  if (opts?.returnUrl) u.searchParams.set("return_url", opts.returnUrl);
345
384
  return u.toString();
346
385
  }
347
- var APP_ID, BASE, TIERS, RANK, FEATURES, getStatus, _cache, isPaid, hasAccess, hasTrialRemaining, showPaywall, isCanceling;
386
+ var APP_ID, OWNER_SLUG, BASE, TIERS, RANK, FEATURES, getStatus, _cache, isPaid, hasAccess, hasTrialRemaining, showPaywall, isCanceling;
348
387
  var init_SparkStripe_feature_gating = __esm({
349
388
  "src/lib/SparkStripe-feature-gating.ts"() {
350
389
  "use strict";
351
390
  APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
352
- BASE = "https://sparkstripe.com";
391
+ OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
392
+ BASE = "https://sparkpay.dev";
353
393
  TIERS = {
354
394
  price_1TADnPEf9opkuWid9NtqOYON: "pro",
355
395
  price_1TADnPEf9opkuWidgbvvX9ma: "pro",
@@ -401,7 +441,7 @@ var VERSION, CONFIG_FILE, DEFAULT_CONFIG, DEFAULT_CAPTIONS;
401
441
  var init_config = __esm({
402
442
  "src/lib/config.ts"() {
403
443
  "use strict";
404
- VERSION = "1.1.1";
444
+ VERSION = "1.2.1";
405
445
  CONFIG_FILE = "quickpeek.config.json";
406
446
  DEFAULT_CONFIG = {
407
447
  plan: { maxSteps: 8 },
@@ -551,7 +591,10 @@ var init_scraping = __esm({
551
591
  });
552
592
 
553
593
  // src/lib/crawl.ts
554
- import { chromium } from "playwright";
594
+ import { chromium as chromium3 } from "playwright";
595
+ function idSelector(id) {
596
+ return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
597
+ }
555
598
  function getActionForType(type) {
556
599
  switch (type) {
557
600
  case "button":
@@ -569,20 +612,29 @@ function getActionForType(type) {
569
612
  return "type";
570
613
  }
571
614
  }
572
- async function crawlPage(url, task2 = noopTask, storageStatePath) {
573
- const cached = crawlCache.get(url);
615
+ async function crawlPage(url, task2 = noopTask, storageStatePath, viewport) {
616
+ const cacheKey = viewport ? `${url}@${viewport.width}x${viewport.height}` : url;
617
+ const cached = crawlCache.get(cacheKey);
574
618
  if (cached && Date.now() - cached.timestamp < CRAWL_CACHE_TTL_MS) {
575
619
  task2("Using cached crawl").succeed(`Found ${cached.elements.length} interactive elements (cached)`);
576
- return { title: cached.title, elements: [...cached.elements] };
620
+ return { title: cached.title, elements: [...cached.elements], scrollable: cached.scrollable };
577
621
  }
578
622
  const browserTask = task2("Launching browser");
579
- const browser = await chromium.launch({ headless: true });
580
- const context = await browser.newContext(storageStatePath ? { storageState: storageStatePath } : {});
623
+ const browser = await chromium3.launch({ headless: true });
624
+ const context = await browser.newContext({
625
+ ...storageStatePath ? { storageState: storageStatePath } : {},
626
+ // Crawl at the size we will record at, or the measurements below describe
627
+ // a page nobody will ever see.
628
+ ...viewport ? { viewport } : {}
629
+ });
581
630
  const page = await context.newPage();
582
631
  browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
583
632
  const crawlTask = task2("Crawling page");
584
633
  await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
585
634
  const title = await page.title();
635
+ const scrollable = await page.evaluate(
636
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
637
+ );
586
638
  const rawElements = await page.evaluate(`
587
639
  (() => {
588
640
  const items = [];
@@ -672,9 +724,9 @@ async function crawlPage(url, task2 = noopTask, storageStatePath) {
672
724
  } else if (el.isFile || el.type === "file") {
673
725
  selector = 'input[type="file"]';
674
726
  } else if (el.type === "range") {
675
- selector = el.id ? `#${el.id}` : 'input[type="range"]';
727
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
676
728
  } else if (el.type === "checkbox") {
677
- selector = el.id ? `#${el.id}` : 'input[type="checkbox"]';
729
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
678
730
  } else if (el.type === "button") {
679
731
  if (btnTexts[el.text] === 1) {
680
732
  selector = `text="${el.text}"`;
@@ -684,7 +736,7 @@ async function crawlPage(url, task2 = noopTask, storageStatePath) {
684
736
  } else if (el.type === "link") {
685
737
  selector = `text="${el.text}"`;
686
738
  } else if (el.id) {
687
- selector = `#${el.id}`;
739
+ selector = idSelector(el.id);
688
740
  } else if (el.name) {
689
741
  selector = `[name="${el.name}"]`;
690
742
  } else if (el.placeholder) {
@@ -712,12 +764,12 @@ async function crawlPage(url, task2 = noopTask, storageStatePath) {
712
764
  });
713
765
  await browser.close();
714
766
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
715
- crawlCache.set(url, { timestamp: Date.now(), title, elements: [...elements] });
767
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
716
768
  if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
717
769
  const oldest = crawlCache.keys().next().value;
718
770
  if (oldest !== void 0) crawlCache.delete(oldest);
719
771
  }
720
- return { title, elements };
772
+ return { title, elements, scrollable };
721
773
  }
722
774
  var noopTask, CRAWL_CACHE_TTL_MS, CRAWL_CACHE_MAX_ENTRIES, crawlCache;
723
775
  var init_crawl = __esm({
@@ -771,7 +823,7 @@ var init_watermark = __esm({
771
823
  });
772
824
 
773
825
  // src/lib/ffmpeg/spawn.ts
774
- import { spawn } from "child_process";
826
+ import { spawn as spawn2 } from "child_process";
775
827
  function toWSLPath(path) {
776
828
  if (!isWindowsRuntime) return path;
777
829
  return windowsDrivePathToWsl(path) ?? path.replace(/\\/g, "/");
@@ -785,9 +837,9 @@ function convertArgToWSL(arg2) {
785
837
  function spawnFFmpeg(cmd2, args) {
786
838
  if (isWindowsRuntime) {
787
839
  const wslArgs = args.map(convertArgToWSL);
788
- return spawn("wsl", ["-e", cmd2, ...wslArgs]);
840
+ return spawn2("wsl", ["-e", cmd2, ...wslArgs]);
789
841
  }
790
- return spawn(cmd2, args);
842
+ return spawn2(cmd2, args);
791
843
  }
792
844
  function runFFmpeg(args, errorLabel) {
793
845
  return new Promise((resolve10, reject) => {
@@ -986,8 +1038,39 @@ function getMediaDuration(filePath) {
986
1038
  proc.on("error", reject);
987
1039
  });
988
1040
  }
1041
+ function getAudioCodec(filePath) {
1042
+ return new Promise((resolve10) => {
1043
+ const args = [
1044
+ "-v",
1045
+ "error",
1046
+ "-select_streams",
1047
+ "a:0",
1048
+ "-show_entries",
1049
+ "stream=codec_name",
1050
+ "-of",
1051
+ "default=noprint_wrappers=1:nokey=1",
1052
+ filePath
1053
+ ];
1054
+ const proc = spawnFFmpeg("ffprobe", args);
1055
+ let stdout = "";
1056
+ proc.stdout.on("data", (data) => {
1057
+ stdout += data;
1058
+ });
1059
+ proc.on("close", (code) => resolve10(code === 0 ? stdout.trim() : ""));
1060
+ proc.on("error", () => resolve10(""));
1061
+ });
1062
+ }
1063
+ async function isHonestWav(filePath) {
1064
+ const codec = await getAudioCodec(filePath);
1065
+ return codec.startsWith("pcm_");
1066
+ }
1067
+ function buildConcatArgs(listPath, output) {
1068
+ const isWav = output.toLowerCase().endsWith(".wav");
1069
+ const codec = isWav ? ["-c:a", "pcm_s16le"] : ["-c", "copy"];
1070
+ return ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...codec, output];
1071
+ }
989
1072
  function concatMedia(listPath, output) {
990
- return runFFmpeg(["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", output], "ffmpeg concat failed");
1073
+ return runFFmpeg(buildConcatArgs(listPath, output), "ffmpeg concat failed");
991
1074
  }
992
1075
  function generateSilence(outputPath, durationMs) {
993
1076
  return runFFmpeg(
@@ -1236,6 +1319,114 @@ var init_ffmpeg2 = __esm({
1236
1319
  }
1237
1320
  });
1238
1321
 
1322
+ // src/lib/json-repair.ts
1323
+ function repairUnicodeEscapes(content) {
1324
+ return content.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (match, hex) => {
1325
+ const code = Number.parseInt(hex, 16);
1326
+ return code <= 1114111 ? String.fromCodePoint(code) : match;
1327
+ }).replace(/\\u(?![0-9a-fA-F]{4})/g, "\\\\u");
1328
+ }
1329
+ function salvageTruncatedJson(content) {
1330
+ const stack = [];
1331
+ const cuts = [];
1332
+ let inString = false;
1333
+ let escaped = false;
1334
+ const record = (index, closesContainer) => cuts.push({ index, open: stack.join(""), closesContainer });
1335
+ for (let i = 0; i < content.length; i++) {
1336
+ const ch = content[i];
1337
+ if (inString) {
1338
+ if (escaped) escaped = false;
1339
+ else if (ch === "\\") escaped = true;
1340
+ else if (ch === '"') {
1341
+ inString = false;
1342
+ record(i + 1, false);
1343
+ }
1344
+ continue;
1345
+ }
1346
+ if (ch === '"') {
1347
+ inString = true;
1348
+ } else if (ch === "{" || ch === "[") {
1349
+ stack.push(ch);
1350
+ } else if (ch === "}" || ch === "]") {
1351
+ stack.pop();
1352
+ record(i + 1, true);
1353
+ } else if (ch === ",") {
1354
+ record(i, content[i - 1] === "}" || content[i - 1] === "]");
1355
+ }
1356
+ }
1357
+ const tryCut = (cut) => {
1358
+ const head = content.slice(0, cut.index).replace(/[\s,]+$/, "");
1359
+ if (head.endsWith(":")) return null;
1360
+ let closed = head;
1361
+ for (let d = cut.open.length - 1; d >= 0; d--) {
1362
+ closed += CLOSERS[cut.open[d]];
1363
+ }
1364
+ try {
1365
+ JSON.parse(closed);
1366
+ return closed;
1367
+ } catch {
1368
+ return null;
1369
+ }
1370
+ };
1371
+ const attempts = Math.min(cuts.length, MAX_SALVAGE_ATTEMPTS);
1372
+ for (const containerOnly of [true, false]) {
1373
+ for (let n = 0; n < attempts; n++) {
1374
+ const cut = cuts[cuts.length - 1 - n];
1375
+ if (containerOnly && !cut.closesContainer) continue;
1376
+ const salvaged = tryCut(cut);
1377
+ if (salvaged !== null) return salvaged;
1378
+ }
1379
+ }
1380
+ return null;
1381
+ }
1382
+ function extractJsonCandidates(content) {
1383
+ let text = content.trim();
1384
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)(?:```|$)/);
1385
+ if (fence?.[1]?.trim()) text = fence[1].trim();
1386
+ const start = text.search(/[{[]/);
1387
+ if (start === -1) return [];
1388
+ const closer = text[start] === "{" ? "}" : "]";
1389
+ const end = text.lastIndexOf(closer);
1390
+ const candidates = [text.slice(start)];
1391
+ if (end > start && end < text.length - 1) candidates.unshift(text.slice(start, end + 1));
1392
+ return candidates;
1393
+ }
1394
+ function parseAIJson(content) {
1395
+ const candidates = extractJsonCandidates(content);
1396
+ if (candidates.length === 0) return null;
1397
+ for (const text of candidates) {
1398
+ try {
1399
+ return { value: JSON.parse(text), repaired: false };
1400
+ } catch {
1401
+ }
1402
+ }
1403
+ for (const text of candidates) {
1404
+ try {
1405
+ return { value: JSON.parse(repairUnicodeEscapes(text)), repaired: true };
1406
+ } catch {
1407
+ }
1408
+ }
1409
+ for (const text of candidates) {
1410
+ for (const variant of [repairUnicodeEscapes(text), text]) {
1411
+ const salvaged = salvageTruncatedJson(variant);
1412
+ if (salvaged === null) continue;
1413
+ try {
1414
+ return { value: JSON.parse(salvaged), repaired: true };
1415
+ } catch {
1416
+ }
1417
+ }
1418
+ }
1419
+ return null;
1420
+ }
1421
+ var MAX_SALVAGE_ATTEMPTS, CLOSERS;
1422
+ var init_json_repair = __esm({
1423
+ "src/lib/json-repair.ts"() {
1424
+ "use strict";
1425
+ MAX_SALVAGE_ATTEMPTS = 500;
1426
+ CLOSERS = { "{": "}", "[": "]" };
1427
+ }
1428
+ });
1429
+
1239
1430
  // src/lib/prompts.ts
1240
1431
  function buildSystemPrompt(maxSteps) {
1241
1432
  let scope;
@@ -1289,7 +1480,7 @@ EXAMPLES:
1289
1480
  \u2022 File upload: "target": "input[type=\\"file\\"]"
1290
1481
  \u2022 Slider: "target": "input[type=\\"range\\"]"`;
1291
1482
  }
1292
- function buildUserPrompt(url, title, elements, langName, description, maxSteps) {
1483
+ function buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable) {
1293
1484
  const lang = langName ? `
1294
1485
  Language: Write ALL captions in ${langName}.` : "";
1295
1486
  const purpose = description ? `
@@ -1327,8 +1518,13 @@ ${selectables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`
1327
1518
  ${checkables.map((e) => `\u2022 "${e.text}" \u2192 ${e.selector}`).join("\n")}`);
1328
1519
  }
1329
1520
  const elementSummary = elementDict.length > 0 ? elementDict.join("\n\n") : "(no interactive elements found)";
1521
+ const cannotScroll = scrollable !== void 0 && scrollable < MIN_USEFUL_SCROLL_PX;
1522
+ const scrolling = cannotScroll ? `
1523
+
1524
+ SCROLLING: this page has only ${scrollable}px of scroll travel at the recording size, so it does not scroll.` : "";
1525
+ const scrollRule = cannotScroll ? "\n\u2022 NEVER use the scroll action: this page does not scroll, so the video would freeze. Use wait and click steps only" : "";
1330
1526
  return `URL: ${url}
1331
- Title: ${title}${purpose}
1527
+ Title: ${title}${purpose}${scrolling}
1332
1528
 
1333
1529
  AVAILABLE ELEMENTS (grouped by action):
1334
1530
  ${elementSummary}
@@ -1338,7 +1534,7 @@ REQUIREMENTS:
1338
1534
  \u2022 Complete ONE user journey: input \u2192 process \u2192 output
1339
1535
  \u2022 For CLICK: use simplified text target like "extract frame" or "download"
1340
1536
  \u2022 For UPLOAD/DRAG/TYPE: use the CSS selector shown above
1341
- \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${lang}
1537
+ \u2022 For buttons that appear after actions: use partial text like "extract" or "download"${scrollRule}${lang}
1342
1538
 
1343
1539
  Return JSON:
1344
1540
  {
@@ -1361,10 +1557,12 @@ TARGET SYNTAX:
1361
1557
  \u2022 Sliders: input[type="range"]
1362
1558
  \u2022 Text inputs: Use selector from list above`;
1363
1559
  }
1560
+ var MIN_USEFUL_SCROLL_PX;
1364
1561
  var init_prompts = __esm({
1365
1562
  "src/lib/prompts.ts"() {
1366
1563
  "use strict";
1367
1564
  init_utils();
1565
+ MIN_USEFUL_SCROLL_PX = 200;
1368
1566
  }
1369
1567
  });
1370
1568
 
@@ -1430,7 +1628,7 @@ var init_styles = __esm({
1430
1628
  async function registerFreeUser(email) {
1431
1629
  const APP_ID2 = process.env.SPARK_APP_ID ?? "quickpeek";
1432
1630
  try {
1433
- const response = await fetch(`https://sparkstripe.com/api/register-free`, {
1631
+ const response = await fetch(`https://sparkpay.dev/api/register-free`, {
1434
1632
  method: "POST",
1435
1633
  headers: { "Content-Type": "application/json" },
1436
1634
  body: JSON.stringify({ email, app_id: APP_ID2, app_name: "QuickPeek" })
@@ -1465,7 +1663,7 @@ var init_subscription_client = __esm({
1465
1663
  });
1466
1664
 
1467
1665
  // src/lib/tts/helpers.ts
1468
- import { existsSync as existsSync3 } from "fs";
1666
+ import { existsSync as existsSync4 } from "fs";
1469
1667
  import { readFile, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
1470
1668
  import { isAbsolute, join as join2, resolve as resolve2 } from "path";
1471
1669
  function toWSLPathForTTS(path) {
@@ -1500,7 +1698,7 @@ async function getMediaStepDuration(step, baseDir) {
1500
1698
  return 0;
1501
1699
  }
1502
1700
  const mediaPath = isAbsolute(step.value) ? step.value : resolve2(baseDir, step.value);
1503
- if (!existsSync3(mediaPath)) {
1701
+ if (!existsSync4(mediaPath)) {
1504
1702
  return 0;
1505
1703
  }
1506
1704
  try {
@@ -1522,7 +1720,7 @@ async function getMediaStepDuration(step, baseDir) {
1522
1720
  async function loadVoiceoverMeta(outputDir) {
1523
1721
  const metaPath = join2(outputDir, "voiceover.json");
1524
1722
  try {
1525
- if (!existsSync3(metaPath)) return null;
1723
+ if (!existsSync4(metaPath)) return null;
1526
1724
  const content = await readFile(metaPath, "utf-8");
1527
1725
  return JSON.parse(content);
1528
1726
  } catch {
@@ -1561,7 +1759,7 @@ __export(timing_exports, {
1561
1759
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
1562
1760
  estimateWordTimings: () => estimateWordTimings
1563
1761
  });
1564
- import { existsSync as existsSync4 } from "fs";
1762
+ import { existsSync as existsSync5 } from "fs";
1565
1763
  import { join as join3 } from "path";
1566
1764
  function estimateWordTimings(caption, duration) {
1567
1765
  const words = caption.split(/\s+/).filter((w) => w.length > 0);
@@ -1598,9 +1796,9 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
1598
1796
  const audioPath = join3(audioDir, `step-${stepNum}.mp3`);
1599
1797
  const originalPaddingPath = join3(audioDir, `step-${stepNum}-padding.mp3`);
1600
1798
  const silencePath = join3(audioDir, `step-${stepNum}-silence.mp3`);
1601
- if (existsSync4(audioPath)) {
1799
+ if (existsSync5(audioPath)) {
1602
1800
  segments.push(audioPath);
1603
- if (existsSync4(originalPaddingPath)) {
1801
+ if (existsSync5(originalPaddingPath)) {
1604
1802
  segments.push(originalPaddingPath);
1605
1803
  }
1606
1804
  if (recordingDuration > voiceDuration) {
@@ -1608,7 +1806,7 @@ async function alignVoiceoverToRecording(stepAudio, actualRecordingDurations, wo
1608
1806
  await generateSilence(alignPaddingPath, recordingDuration - voiceDuration);
1609
1807
  segments.push(alignPaddingPath);
1610
1808
  }
1611
- } else if (existsSync4(silencePath)) {
1809
+ } else if (existsSync5(silencePath)) {
1612
1810
  if (targetDuration !== voiceDuration) {
1613
1811
  await generateSilence(silencePath, targetDuration);
1614
1812
  }
@@ -1696,10 +1894,25 @@ var init_voices = __esm({
1696
1894
 
1697
1895
  // src/lib/tts/synthesize.ts
1698
1896
  import { exec } from "child_process";
1699
- import { writeFile as writeFile3 } from "fs/promises";
1897
+ import { createWriteStream } from "fs";
1898
+ import { stat, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
1700
1899
  import { join as join5, resolve as resolve3 } from "path";
1900
+ import { pipeline } from "stream/promises";
1701
1901
  import { promisify } from "util";
1702
1902
  async function synthesizeSpeech(text, outputPath, voice) {
1903
+ let pythonMsg = "";
1904
+ for (let attempt = 0; attempt < PYTHON_ATTEMPTS; attempt++) {
1905
+ try {
1906
+ await synthesizeWithPython(text, outputPath, voice);
1907
+ return;
1908
+ } catch (err) {
1909
+ pythonMsg = err instanceof Error ? err.message : String(err);
1910
+ if (attempt < PYTHON_ATTEMPTS - 1) await new Promise((r) => setTimeout(r, 500));
1911
+ }
1912
+ }
1913
+ await synthesizeWithNode(text, outputPath, voice, pythonMsg);
1914
+ }
1915
+ async function synthesizeWithPython(text, outputPath, voice) {
1703
1916
  const tempScriptPath = join5(resolve3(outputPath, ".."), `tts-${Date.now()}.py`);
1704
1917
  const wslScriptPath = isWindows ? toWSLPathForTTS(resolve3(tempScriptPath)) : tempScriptPath;
1705
1918
  const wslOutputPath = isWindows ? toWSLPathForTTS(resolve3(outputPath)) : outputPath;
@@ -1720,42 +1933,45 @@ asyncio.run(main())
1720
1933
  await writeFile3(tempScriptPath, pythonScript);
1721
1934
  const pythonCmd = isWindows ? `wsl python3 "${wslScriptPath}"` : `python3 "${wslScriptPath}"`;
1722
1935
  await execAsync(pythonCmd, { timeout: 3e4 });
1723
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1724
- }));
1725
- return;
1726
- } catch (pythonErr) {
1727
- await import("fs/promises").then((fs) => fs.unlink(tempScriptPath).catch(() => {
1728
- }));
1729
- const pythonMsg = pythonErr instanceof Error ? pythonErr.message : String(pythonErr);
1730
- const maxRetries = 2;
1731
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1732
- try {
1733
- const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1734
- const tts = new MsEdgeTTS();
1735
- await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1736
- await tts.toFile(outputPath, text);
1737
- return;
1738
- } catch (nodeErr) {
1739
- if (attempt === maxRetries) {
1740
- const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1741
- throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1742
- }
1743
- await new Promise((r) => setTimeout(r, 500));
1936
+ } finally {
1937
+ await unlink3(tempScriptPath).catch(() => {
1938
+ });
1939
+ }
1940
+ }
1941
+ async function synthesizeWithNode(text, outputPath, voice, pythonMsg, maxRetries = 2) {
1942
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1943
+ try {
1944
+ const { MsEdgeTTS, OUTPUT_FORMAT } = await import("msedge-tts");
1945
+ const tts = new MsEdgeTTS();
1946
+ await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
1947
+ const { audioStream } = tts.toStream(text);
1948
+ await pipeline(audioStream, createWriteStream(outputPath));
1949
+ const { size } = await stat(outputPath);
1950
+ if (size === 0) throw new Error("No audio data received");
1951
+ return;
1952
+ } catch (nodeErr) {
1953
+ await unlink3(outputPath).catch(() => {
1954
+ });
1955
+ if (attempt === maxRetries) {
1956
+ const nodeMsg = nodeErr instanceof Error ? nodeErr.message : String(nodeErr);
1957
+ throw new Error(`TTS failed (Python: ${pythonMsg.slice(0, 100)}, Node: ${nodeMsg.slice(0, 100)})`);
1744
1958
  }
1959
+ await new Promise((r) => setTimeout(r, 500));
1745
1960
  }
1746
1961
  }
1747
1962
  }
1748
- var execAsync;
1963
+ var execAsync, PYTHON_ATTEMPTS;
1749
1964
  var init_synthesize = __esm({
1750
1965
  "src/lib/tts/synthesize.ts"() {
1751
1966
  "use strict";
1752
1967
  init_helpers();
1753
1968
  execAsync = promisify(exec);
1969
+ PYTHON_ATTEMPTS = 2;
1754
1970
  }
1755
1971
  });
1756
1972
 
1757
1973
  // src/lib/tts/generate.ts
1758
- import { mkdir as mkdir2 } from "fs/promises";
1974
+ import { mkdir as mkdir3 } from "fs/promises";
1759
1975
  import { join as join6 } from "path";
1760
1976
  function estimateDurationFromText(text, wordsPerMinute = 150) {
1761
1977
  const words = text.split(/\s+/).filter((w) => w.length > 0).length;
@@ -1789,7 +2005,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
1789
2005
  return { voiceoverPath: "", stepAudio: stepAudio2 };
1790
2006
  }
1791
2007
  const audioDir = join6(workDir, "audio");
1792
- await mkdir2(audioDir, { recursive: true });
2008
+ await mkdir3(audioDir, { recursive: true });
1793
2009
  const segments = [];
1794
2010
  const stepAudio = /* @__PURE__ */ new Map();
1795
2011
  const ttsTask = task2("Generating voiceover");
@@ -1840,7 +2056,7 @@ async function generateVoiceover(steps, config, workDir, outputDir, logError2, t
1840
2056
  return { voiceoverPath: "", stepAudio };
1841
2057
  }
1842
2058
  const concatTask = task2("Combining audio");
1843
- await mkdir2(outputDir, { recursive: true });
2059
+ await mkdir3(outputDir, { recursive: true });
1844
2060
  const voiceoverPath = join6(outputDir, "voiceover.wav");
1845
2061
  await concatenateAudio(segments, voiceoverPath, workDir);
1846
2062
  concatTask.succeed("Done");
@@ -1902,6 +2118,7 @@ var init_types3 = __esm({
1902
2118
  // src/lib/index.ts
1903
2119
  var lib_exports = {};
1904
2120
  __export(lib_exports, {
2121
+ CLI_BACKOFF_MS: () => CLI_BACKOFF_MS,
1905
2122
  CONFIG_FILE: () => CONFIG_FILE,
1906
2123
  CURSOR_CSS: () => CURSOR_CSS,
1907
2124
  CURSOR_PNG: () => CURSOR_PNG,
@@ -1912,9 +2129,11 @@ __export(lib_exports, {
1912
2129
  FEATURES: () => FEATURES,
1913
2130
  INTERACTIVE_SELECTORS: () => INTERACTIVE_SELECTORS,
1914
2131
  LANGUAGE_VOICES: () => WIDE_VOICES,
2132
+ SERVER_BACKOFF_MS: () => SERVER_BACKOFF_MS,
1915
2133
  VERSION: () => VERSION,
1916
2134
  alignVoiceoverToRecording: () => alignVoiceoverToRecording,
1917
2135
  applyTransitions: () => applyTransitions,
2136
+ buildConcatArgs: () => buildConcatArgs,
1918
2137
  buildKeepSegments: () => buildKeepSegments,
1919
2138
  buildStepTransitions: () => buildStepTransitions,
1920
2139
  buildSystemPrompt: () => buildSystemPrompt,
@@ -1928,9 +2147,11 @@ __export(lib_exports, {
1928
2147
  concatVideoWithOutro: () => concatVideoWithOutro,
1929
2148
  crawlPage: () => crawlPage,
1930
2149
  estimateWordTimings: () => estimateWordTimings,
2150
+ extractJsonCandidates: () => extractJsonCandidates,
1931
2151
  generateASSHeader: () => generateASSHeader,
1932
2152
  generateSilence: () => generateSilence,
1933
2153
  generateVoiceover: () => generateVoiceover,
2154
+ getAudioCodec: () => getAudioCodec,
1934
2155
  getErrorMessage: () => getErrorMessage,
1935
2156
  getLanguageName: () => getLanguageName,
1936
2157
  getMediaDuration: () => getMediaDuration,
@@ -1941,20 +2162,25 @@ __export(lib_exports, {
1941
2162
  hasAccess: () => hasAccess,
1942
2163
  hasTrialRemaining: () => hasTrialRemaining,
1943
2164
  hexToASS: () => hexToAss,
2165
+ idSelector: () => idSelector,
1944
2166
  isCanceling: () => isCanceling,
1945
2167
  isExcludedLink: () => isExcludedLink,
2168
+ isHonestWav: () => isHonestWav,
1946
2169
  isPaid: () => isPaid,
1947
2170
  isVerified: () => isVerified,
1948
2171
  loadExistingVoiceover: () => loadExistingVoiceover,
1949
2172
  loadVoiceoverMeta: () => loadVoiceoverMeta,
1950
2173
  mapTimestampsAfterCompression: () => mapTimestampsAfterCompression,
1951
2174
  normalizeUrl: () => normalizeUrl,
2175
+ parseAIJson: () => parseAIJson,
1952
2176
  parseAIPlanResponse: () => parseAIPlanResponse,
1953
2177
  parsePartial: () => parsePartial,
1954
2178
  pricingUrl: () => pricingUrl,
1955
2179
  registerFreeUser: () => registerFreeUser,
2180
+ repairUnicodeEscapes: () => repairUnicodeEscapes,
1956
2181
  resolveVoice: () => resolveVoice,
1957
2182
  rgbaToHex: () => rgbaToHex,
2183
+ salvageTruncatedJson: () => salvageTruncatedJson,
1958
2184
  shouldSkipLink: () => shouldSkipLink,
1959
2185
  shouldWatermark: () => shouldWatermark,
1960
2186
  showPaywall: () => showPaywall,
@@ -1973,6 +2199,7 @@ var init_lib = __esm({
1973
2199
  init_crawl();
1974
2200
  init_cursor();
1975
2201
  init_ffmpeg2();
2202
+ init_json_repair();
1976
2203
  init_prompts();
1977
2204
  init_SparkStripe_feature_gating();
1978
2205
  init_scraping();
@@ -1996,13 +2223,13 @@ __export(config_loader_exports, {
1996
2223
  savePlan: () => savePlan,
1997
2224
  saveUserData: () => saveUserData
1998
2225
  });
1999
- import { existsSync as existsSync5 } from "fs";
2000
- import { mkdir as mkdir3, readFile as readFile2, rm, writeFile as writeFile4 } from "fs/promises";
2226
+ import { existsSync as existsSync6 } from "fs";
2227
+ import { mkdir as mkdir4, readFile as readFile2, rm, writeFile as writeFile4 } from "fs/promises";
2001
2228
  import { homedir as homedir2 } from "os";
2002
2229
  import { dirname, join as join7 } from "path";
2003
2230
  async function loadConfig(_url, langOverride) {
2004
2231
  const configPath = join7(process.cwd(), CONFIG_FILE);
2005
- if (existsSync5(configPath)) {
2232
+ if (existsSync6(configPath)) {
2006
2233
  try {
2007
2234
  const content = await readFile2(configPath, "utf-8");
2008
2235
  const userConfig = JSON.parse(content);
@@ -2052,15 +2279,15 @@ async function loadConfig(_url, langOverride) {
2052
2279
  }
2053
2280
  async function prepareWorkDir() {
2054
2281
  const workDir = join7(process.cwd(), WORK_DIR);
2055
- if (existsSync5(workDir)) {
2282
+ if (existsSync6(workDir)) {
2056
2283
  await rm(workDir, { recursive: true });
2057
2284
  }
2058
- await mkdir3(workDir, { recursive: true });
2285
+ await mkdir4(workDir, { recursive: true });
2059
2286
  return workDir;
2060
2287
  }
2061
2288
  async function loadUserData() {
2062
2289
  try {
2063
- if (existsSync5(USER_DATA_FILE)) {
2290
+ if (existsSync6(USER_DATA_FILE)) {
2064
2291
  return JSON.parse(await readFile2(USER_DATA_FILE, "utf-8"));
2065
2292
  }
2066
2293
  } catch {
@@ -2068,8 +2295,8 @@ async function loadUserData() {
2068
2295
  return {};
2069
2296
  }
2070
2297
  async function saveUserData(userData) {
2071
- if (!existsSync5(USER_DATA_DIR)) {
2072
- await mkdir3(USER_DATA_DIR, { recursive: true });
2298
+ if (!existsSync6(USER_DATA_DIR)) {
2299
+ await mkdir4(USER_DATA_DIR, { recursive: true });
2073
2300
  }
2074
2301
  const existing = await loadUserData();
2075
2302
  const merged = { ...existing, ...userData };
@@ -2122,7 +2349,7 @@ function parseCaptionCSS(css) {
2122
2349
  }
2123
2350
  async function loadCaptions() {
2124
2351
  const captionsPath = join7(process.cwd(), "captions.css");
2125
- if (!existsSync5(captionsPath)) {
2352
+ if (!existsSync6(captionsPath)) {
2126
2353
  await writeFile4(captionsPath, DEFAULT_CAPTIONS_CSS);
2127
2354
  return DEFAULT_CAPTIONS;
2128
2355
  }
@@ -2138,7 +2365,7 @@ async function savePlan(plan, planPath) {
2138
2365
  ...plan,
2139
2366
  steps: plan.steps.map(({ duration, ...step }) => step)
2140
2367
  };
2141
- await mkdir3(dirname(planPath), { recursive: true });
2368
+ await mkdir4(dirname(planPath), { recursive: true });
2142
2369
  await writeFile4(planPath, JSON.stringify(cleanPlan, null, 2));
2143
2370
  }
2144
2371
  var WORK_DIR, USER_DATA_DIR, USER_DATA_FILE, DEFAULT_CAPTIONS_CSS;
@@ -2246,16 +2473,16 @@ function fileResult(output, data, poster) {
2246
2473
  }
2247
2474
 
2248
2475
  // src/mcp/tools.ts
2249
- import { readdirSync as readdirSync4, statSync as statSync2 } from "fs";
2250
- import { join as join22 } from "path";
2476
+ import { readdirSync as readdirSync5, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
2477
+ import { join as join23 } from "path";
2251
2478
  import { WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
2252
2479
 
2253
2480
  // src/browser/auth-state.ts
2254
- import { existsSync as existsSync2 } from "fs";
2481
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
2255
2482
  import { chmod, mkdir, readdir, unlink, writeFile } from "fs/promises";
2256
2483
  import { homedir } from "os";
2257
2484
  import { join } from "path";
2258
- var AUTH_DIR = join(homedir(), ".quickpeek", "auth");
2485
+ var AUTH_DIR = process.env.QUICKPEEK_AUTH_DIR || join(homedir(), ".quickpeek", "auth");
2259
2486
  function hostOf(url) {
2260
2487
  try {
2261
2488
  return new URL(url).host;
@@ -2267,145 +2494,551 @@ function authStatePath(url) {
2267
2494
  const host = hostOf(url);
2268
2495
  return host ? join(AUTH_DIR, `${host}.json`) : null;
2269
2496
  }
2497
+ function authMetaPath(url) {
2498
+ const host = hostOf(url);
2499
+ return host ? join(AUTH_DIR, `${host}.meta.json`) : null;
2500
+ }
2270
2501
  function findAuthState(url) {
2271
2502
  const path = authStatePath(url);
2272
2503
  return path && existsSync2(path) ? path : void 0;
2273
2504
  }
2274
-
2275
- // src/commands/record-cmd.ts
2276
- import { existsSync as existsSync17 } from "fs";
2277
- import { mkdir as mkdir8 } from "fs/promises";
2278
- import { isAbsolute as isAbsolute5, join as join20, resolve as resolve9 } from "path";
2279
- import chalk14 from "chalk";
2280
-
2281
- // src/help.ts
2282
- init_lib();
2283
- import chalk from "chalk";
2284
-
2285
- // src/logo.ts
2286
- import figlet from "figlet";
2287
- import gradient from "gradient-string";
2288
- var DEFAULT_LOGO_CONFIG = {
2289
- text: "QuickPeek",
2290
- font: "Slant",
2291
- horizontalLayout: "default",
2292
- gradientColors: ["#00ffff", "#00d4ff", "#a855f7", "#ec4899"],
2293
- suffix: " \u{1F440}"
2294
- };
2295
- function renderLogo(config = {}) {
2296
- const resolved = { ...DEFAULT_LOGO_CONFIG, ...config };
2297
- const ascii = figlet.textSync(resolved.text, {
2298
- font: resolved.font,
2299
- horizontalLayout: resolved.horizontalLayout
2300
- });
2301
- const colored = gradient(resolved.gradientColors)(ascii);
2302
- return resolved.suffix ? `${colored}${resolved.suffix}` : colored;
2303
- }
2304
-
2305
- // src/help.ts
2306
- init_config_loader();
2307
- var cmd = chalk.cyan;
2308
- var arg = chalk.green;
2309
- var opt = chalk.magenta;
2310
- var desc = chalk.white;
2311
- var ex = chalk.dim;
2312
- function showLogo() {
2313
- const logo = renderLogo();
2314
- console.log(logo);
2315
- console.log(chalk.dim(` v${VERSION} - Auto-generate demo videos with AI`));
2316
- console.log(chalk.italic.dim(" Because Tutorial Videos are Overrated!\n"));
2317
- }
2318
- function formatUsageLines(status, tier) {
2319
- const lines = [];
2320
- lines.push(`Videos generated: ${status.usage_count}`);
2321
- if (tier !== "pro" && status.trial) {
2322
- const { uses_remaining, max_uses, days_remaining, is_expired } = status.trial;
2323
- const used = max_uses - uses_remaining;
2324
- lines.push(`Trial: ${used}/${max_uses} uses${is_expired ? " (expired)" : ` (${uses_remaining} remaining)`}`);
2325
- if (!is_expired && days_remaining > 0) {
2326
- lines.push(`Trial ends in ${days_remaining} day${days_remaining === 1 ? "" : "s"}`);
2327
- }
2328
- }
2329
- if (tier === "pro" && status.subscription) {
2330
- const end = status.subscription.current_period_end;
2331
- if (status.subscription.is_lifetime) {
2332
- lines.push("Lifetime access");
2333
- } else if (isCanceling(status)) {
2334
- const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2335
- lines.push(`Cancels: ${date}`);
2336
- } else if (end) {
2337
- const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2338
- lines.push(`Renews: ${date}`);
2339
- }
2505
+ async function saveAuthState(url, state, startUrl) {
2506
+ const path = authStatePath(url);
2507
+ if (!path) throw new Error(`Not a valid URL: ${url}`);
2508
+ await mkdir(AUTH_DIR, { recursive: true });
2509
+ await writeFile(path, JSON.stringify(state, null, 2));
2510
+ await chmod(path, 384);
2511
+ if (startUrl) {
2512
+ await writeFile(authMetaPath(url), JSON.stringify({ startUrl, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
2513
+ }
2514
+ return path;
2515
+ }
2516
+ function findStartUrl(url) {
2517
+ const path = authMetaPath(url);
2518
+ if (!path || !existsSync2(path)) return void 0;
2519
+ try {
2520
+ const startUrl = JSON.parse(readFileSync2(path, "utf8")).startUrl;
2521
+ return typeof startUrl === "string" && startUrl.startsWith("http") ? startUrl : void 0;
2522
+ } catch {
2523
+ return void 0;
2340
2524
  }
2341
- return lines;
2342
2525
  }
2343
-
2344
- // src/commands/record-cmd.ts
2345
- init_lib();
2346
- init_config_loader();
2347
-
2348
- // src/utils/logging.ts
2349
- import { appendFile, writeFile as writeFile5 } from "fs/promises";
2350
- import { join as join8 } from "path";
2351
- var LOG_FILE_NAME = "quickpeek.log";
2352
- var logFileInitialized = false;
2353
- var logFilePath = LOG_FILE_NAME;
2354
- async function writeLogEntry(entry) {
2526
+ function resolveStartUrl(url) {
2355
2527
  try {
2356
- if (!logFileInitialized) {
2357
- await writeFile5(logFilePath, entry);
2358
- logFileInitialized = true;
2359
- } else {
2360
- await appendFile(logFilePath, entry);
2361
- }
2528
+ const parsed = new URL(url);
2529
+ const bareOrigin = (parsed.pathname === "/" || parsed.pathname === "") && !parsed.search && !parsed.hash;
2530
+ if (!bareOrigin || !findAuthState(url)) return url;
2531
+ return findStartUrl(url) ?? url;
2362
2532
  } catch {
2533
+ return url;
2363
2534
  }
2364
2535
  }
2365
- async function setLogFilePath(outputDir) {
2366
- logFilePath = join8(outputDir, LOG_FILE_NAME);
2367
- const header2 = `QuickPeek Log - ${(/* @__PURE__ */ new Date()).toISOString()}
2368
- ${"=".repeat(60)}
2369
2536
 
2370
- `;
2537
+ // src/browser/launch.ts
2538
+ import { mkdir as mkdir2 } from "fs/promises";
2539
+ import { chromium as chromium2 } from "playwright";
2540
+
2541
+ // src/browser/wsl.ts
2542
+ import { execSync, spawn } from "child_process";
2543
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2544
+ import { request } from "http";
2545
+ import { chromium } from "playwright";
2546
+ function isWSL() {
2547
+ if (process.platform !== "linux") return false;
2371
2548
  try {
2372
- await writeFile5(logFilePath, header2);
2373
- logFileInitialized = true;
2549
+ const r = execSync("uname -r", { encoding: "utf-8" }).toLowerCase();
2550
+ return r.includes("microsoft") || r.includes("wsl");
2374
2551
  } catch {
2552
+ return false;
2375
2553
  }
2376
2554
  }
2377
- async function logInfo(context, message) {
2378
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [INFO] [${context}] ${message}
2379
- `);
2555
+ function tryEnableWSLInterop() {
2556
+ return existsSync3("/proc/sys/fs/binfmt_misc/WSLInterop");
2380
2557
  }
2381
- async function logDebug(context, message) {
2382
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] [${context}] ${message}
2383
- `);
2558
+ function wslToWindowsPath(wslPath) {
2559
+ if (!wslPath) return wslPath;
2560
+ const m = wslPath.match(/^\/mnt\/([a-z])\/(.*)/);
2561
+ if (!m) return wslPath;
2562
+ return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
2384
2563
  }
2385
- async function logError(context, error) {
2386
- let errorMsg;
2387
- if (error instanceof Error) {
2388
- errorMsg = `${error.message}
2389
- ${error.stack}`;
2390
- } else if (typeof error === "object" && error !== null) {
2391
- errorMsg = JSON.stringify(error, null, 2);
2392
- } else {
2393
- errorMsg = String(error);
2564
+ function findExe(whereExeQuery, ...hardcoded) {
2565
+ for (const p of hardcoded) if (existsSync3(p)) return p;
2566
+ try {
2567
+ const line = execSync(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
2568
+ if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
2569
+ } catch {
2394
2570
  }
2395
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [ERROR] [${context}]
2396
- ${errorMsg}
2397
-
2398
- `);
2571
+ return null;
2399
2572
  }
2400
- async function logStep(stepNum, action, details) {
2401
- const detailsStr = Object.entries(details).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ");
2402
- await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [STEP ${stepNum}] ${action} | ${detailsStr}
2403
- `);
2573
+ var findWindowsChrome = () => findExe(
2574
+ "chrome.exe",
2575
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
2576
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
2577
+ );
2578
+ var findWindowsNode = () => findExe("node.exe");
2579
+ function getWindowsHostIP() {
2580
+ try {
2581
+ const m = execSync("ip route show default", { encoding: "utf-8", timeout: 2e3 }).match(
2582
+ /default via (\d+\.\d+\.\d+\.\d+)/
2583
+ );
2584
+ if (m?.[1]) return m[1];
2585
+ } catch {
2586
+ }
2587
+ try {
2588
+ const m = readFileSync3("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
2589
+ if (m?.[1]) return m[1];
2590
+ } catch {
2591
+ }
2592
+ return "127.0.0.1";
2404
2593
  }
2405
-
2406
- // src/utils/ui.ts
2407
- import { resolve as resolve4 } from "path";
2408
- import { createInterface } from "readline";
2594
+ function startRelay(nodePath, windowsHost, targetPort) {
2595
+ const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
2596
+ return new Promise((resolve10, reject) => {
2597
+ const proc = spawn(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
2598
+ const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
2599
+ let buf = "";
2600
+ proc.stdout.on("data", (d) => {
2601
+ buf += d.toString();
2602
+ const m = buf.match(/PORT:(\d+)/);
2603
+ if (m) {
2604
+ clearTimeout(timer);
2605
+ resolve10({ proc, port: +m[1] });
2606
+ }
2607
+ });
2608
+ proc.on("error", (e) => {
2609
+ clearTimeout(timer);
2610
+ reject(e);
2611
+ });
2612
+ });
2613
+ }
2614
+ function waitForChrome(host, port) {
2615
+ return new Promise((resolve10, reject) => {
2616
+ const deadline = Date.now() + 2e4;
2617
+ const poll = () => {
2618
+ if (Date.now() > deadline) {
2619
+ reject(new Error("Chrome debug port did not open within 20 s"));
2620
+ return;
2621
+ }
2622
+ const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
2623
+ res.resume();
2624
+ resolve10();
2625
+ });
2626
+ req.on("error", () => setTimeout(poll, 600));
2627
+ req.setTimeout(1e3, () => {
2628
+ req.destroy();
2629
+ setTimeout(poll, 600);
2630
+ });
2631
+ req.end();
2632
+ };
2633
+ poll();
2634
+ });
2635
+ }
2636
+ async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
2637
+ const debugPort = options.debugPort ?? 9222;
2638
+ const chromePath = findWindowsChrome();
2639
+ if (!chromePath) throw new Error("Windows Chrome not found. Install Google Chrome for Windows.");
2640
+ const nodePath = findWindowsNode();
2641
+ if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
2642
+ const winUserData = wslToWindowsPath(userDataDir);
2643
+ const winExtPath = extensionPath ? wslToWindowsPath(extensionPath) : "";
2644
+ const chromeProc = spawn(
2645
+ chromePath,
2646
+ [
2647
+ `--remote-debugging-port=${debugPort}`,
2648
+ `--user-data-dir=${winUserData}`,
2649
+ ...winExtPath ? [`--disable-extensions-except=${winExtPath}`, `--load-extension=${winExtPath}`] : [],
2650
+ "--no-first-run",
2651
+ "--no-default-browser-check",
2652
+ "--disable-background-networking",
2653
+ "--disable-sync",
2654
+ "--disable-features=TranslateUI",
2655
+ options.startMaximized ? "--start-maximized" : "--start-minimized"
2656
+ ],
2657
+ { stdio: "ignore", detached: true }
2658
+ );
2659
+ chromeProc.unref();
2660
+ const windowsHost = getWindowsHostIP();
2661
+ const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
2662
+ try {
2663
+ await waitForChrome(windowsHost, proxyPort);
2664
+ const browser = await chromium.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
2665
+ return {
2666
+ browser,
2667
+ cleanup: async () => {
2668
+ try {
2669
+ await browser.close();
2670
+ } catch {
2671
+ }
2672
+ relay.kill();
2673
+ }
2674
+ };
2675
+ } catch (err) {
2676
+ relay.kill();
2677
+ throw err;
2678
+ }
2679
+ }
2680
+
2681
+ // src/browser/launch.ts
2682
+ function recordingViewport(config) {
2683
+ const zoom = config.video.zoom && config.video.zoom > 0 ? config.video.zoom : 1;
2684
+ return {
2685
+ width: Math.round(config.video.width / zoom),
2686
+ height: Math.round(config.video.height / zoom)
2687
+ };
2688
+ }
2689
+ async function launchBrowserContext(config, userDataDir, recordVideo, storageStatePath) {
2690
+ const extensionPath = config.browser?.extension;
2691
+ const zoom = config.video.zoom && config.video.zoom > 0 ? config.video.zoom : 1;
2692
+ const viewport = recordingViewport(config);
2693
+ const scaleOpts = zoom === 1 ? {} : { deviceScaleFactor: zoom };
2694
+ const videoOpts = recordVideo ? { recordVideo } : {};
2695
+ const cspOpts = { bypassCSP: true };
2696
+ const authOpts = storageStatePath ? { storageState: storageStatePath } : {};
2697
+ let context;
2698
+ let browser = null;
2699
+ let chromeSession = null;
2700
+ if (extensionPath) {
2701
+ await mkdir2(userDataDir, { recursive: true });
2702
+ const persistentArgs = [
2703
+ `--disable-extensions-except=${extensionPath}`,
2704
+ `--load-extension=${extensionPath}`,
2705
+ "--window-position=-32000,-32000",
2706
+ "--window-size=1,1"
2707
+ ];
2708
+ const useWindowsChrome = isWSL() && tryEnableWSLInterop();
2709
+ if (useWindowsChrome) {
2710
+ try {
2711
+ chromeSession = await launchWindowsChrome(extensionPath, userDataDir, { viewport, ...scaleOpts, ...videoOpts });
2712
+ const contexts = chromeSession.browser.contexts();
2713
+ context = contexts[0] || await chromeSession.browser.newContext({
2714
+ viewport,
2715
+ ...scaleOpts,
2716
+ ...videoOpts,
2717
+ ...cspOpts,
2718
+ acceptDownloads: true
2719
+ });
2720
+ } catch {
2721
+ context = await chromium2.launchPersistentContext(userDataDir, {
2722
+ headless: false,
2723
+ args: persistentArgs,
2724
+ viewport,
2725
+ ...scaleOpts,
2726
+ ...videoOpts,
2727
+ ...cspOpts,
2728
+ acceptDownloads: true
2729
+ });
2730
+ }
2731
+ } else {
2732
+ context = await chromium2.launchPersistentContext(userDataDir, {
2733
+ headless: false,
2734
+ args: persistentArgs,
2735
+ viewport,
2736
+ ...scaleOpts,
2737
+ ...videoOpts,
2738
+ ...cspOpts,
2739
+ acceptDownloads: true
2740
+ });
2741
+ }
2742
+ } else {
2743
+ if (process.env.QP_HEADED === "1") {
2744
+ const headedOpts = {
2745
+ headless: false,
2746
+ args: ["--disable-blink-features=AutomationControlled"],
2747
+ ignoreDefaultArgs: ["--enable-automation"]
2748
+ };
2749
+ try {
2750
+ browser = await chromium2.launch({ ...headedOpts, channel: "chrome" });
2751
+ } catch {
2752
+ browser = await chromium2.launch(headedOpts);
2753
+ }
2754
+ } else {
2755
+ browser = await chromium2.launch({ headless: true });
2756
+ }
2757
+ context = await browser.newContext({
2758
+ viewport,
2759
+ ...scaleOpts,
2760
+ ...videoOpts,
2761
+ ...authOpts,
2762
+ ...cspOpts,
2763
+ acceptDownloads: true
2764
+ });
2765
+ }
2766
+ await hideDevOverlays(context);
2767
+ return { context, browser, chromeSession };
2768
+ }
2769
+ async function hideDevOverlays(context) {
2770
+ await context.addInitScript(() => {
2771
+ const css = "nextjs-portal,vite-error-overlay,vite-plugin-checker-error-overlay{display:none!important;pointer-events:none!important}";
2772
+ const add = () => {
2773
+ const style = document.createElement("style");
2774
+ style.textContent = css;
2775
+ document.head?.appendChild(style);
2776
+ };
2777
+ if (document.head) add();
2778
+ else document.addEventListener("DOMContentLoaded", add, { once: true });
2779
+ }).catch(() => {
2780
+ });
2781
+ }
2782
+ async function launchSignInBrowser() {
2783
+ const opts = {
2784
+ headless: false,
2785
+ args: ["--disable-blink-features=AutomationControlled"],
2786
+ ignoreDefaultArgs: ["--enable-automation"]
2787
+ };
2788
+ try {
2789
+ return await chromium2.launch({ ...opts, channel: "chrome" });
2790
+ } catch {
2791
+ return await chromium2.launch(opts);
2792
+ }
2793
+ }
2794
+ async function cleanupBrowserSession(session) {
2795
+ if (session.chromeSession) {
2796
+ await session.chromeSession.cleanup();
2797
+ } else {
2798
+ await session.context.close();
2799
+ if (session.browser) await session.browser.close();
2800
+ }
2801
+ }
2802
+
2803
+ // src/browser/auth-session.ts
2804
+ var pending = null;
2805
+ function pendingSignIn() {
2806
+ return pending?.url ?? null;
2807
+ }
2808
+ async function openSignIn(url) {
2809
+ await cancelSignIn();
2810
+ const browser = await launchSignInBrowser();
2811
+ const context = await browser.newContext();
2812
+ const page = await context.newPage();
2813
+ pending = { browser, context, url };
2814
+ try {
2815
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2816
+ } catch {
2817
+ }
2818
+ }
2819
+ function sameSite(a, b) {
2820
+ if (a === b) return true;
2821
+ const tail = (h) => h.split(".").slice(-2).join(".");
2822
+ return a.includes(".") && b.includes(".") && tail(a) === tail(b);
2823
+ }
2824
+ function captureStartUrl(context, targetUrl) {
2825
+ const targetHost = new URL(targetUrl).host;
2826
+ const pages = context.pages();
2827
+ for (let i = pages.length - 1; i >= 0; i--) {
2828
+ const pageUrl = pages[i].url();
2829
+ if (!pageUrl.startsWith("http")) continue;
2830
+ if (sameSite(new URL(pageUrl).host, targetHost)) return pageUrl;
2831
+ }
2832
+ return void 0;
2833
+ }
2834
+ async function saveSignIn() {
2835
+ if (!pending) throw new Error("No sign-in window is open. Call start_auth first.");
2836
+ const { context, url } = pending;
2837
+ try {
2838
+ const startUrl = captureStartUrl(context, url);
2839
+ const path = await saveAuthState(url, await context.storageState(), startUrl);
2840
+ return { host: new URL(url).host, path, ...startUrl ? { startUrl } : {} };
2841
+ } finally {
2842
+ await cancelSignIn();
2843
+ }
2844
+ }
2845
+ async function cancelSignIn() {
2846
+ const open = pending;
2847
+ pending = null;
2848
+ await open?.browser.close().catch(() => {
2849
+ });
2850
+ }
2851
+
2852
+ // src/commands/edit-cmd.ts
2853
+ import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync4 } from "fs";
2854
+ import { join as join10 } from "path";
2855
+ import chalk7 from "chalk";
2856
+
2857
+ // src/help.ts
2858
+ init_lib();
2859
+ import chalk from "chalk";
2860
+
2861
+ // src/logo.ts
2862
+ import figlet from "figlet";
2863
+ import gradient from "gradient-string";
2864
+ var DEFAULT_LOGO_CONFIG = {
2865
+ text: "QuickPeek",
2866
+ font: "Slant",
2867
+ horizontalLayout: "default",
2868
+ gradientColors: ["#00ffff", "#00d4ff", "#a855f7", "#ec4899"],
2869
+ suffix: " \u{1F440}"
2870
+ };
2871
+ function renderLogo(config = {}) {
2872
+ const resolved = { ...DEFAULT_LOGO_CONFIG, ...config };
2873
+ const ascii = figlet.textSync(resolved.text, {
2874
+ font: resolved.font,
2875
+ horizontalLayout: resolved.horizontalLayout
2876
+ });
2877
+ const colored = gradient(resolved.gradientColors)(ascii);
2878
+ return resolved.suffix ? `${colored}${resolved.suffix}` : colored;
2879
+ }
2880
+
2881
+ // src/help.ts
2882
+ init_config_loader();
2883
+ var cmd = chalk.cyan;
2884
+ var arg = chalk.green;
2885
+ var opt = chalk.magenta;
2886
+ var desc = chalk.white;
2887
+ var ex = chalk.dim;
2888
+ function showLogo() {
2889
+ const logo = renderLogo();
2890
+ console.log(logo);
2891
+ console.log(chalk.dim(` v${VERSION} - Auto-generate demo videos with AI`));
2892
+ console.log(chalk.italic.dim(" Because Tutorial Videos are Overrated!\n"));
2893
+ }
2894
+ function formatUsageLines(status, tier) {
2895
+ const lines = [];
2896
+ lines.push(`Videos generated: ${status.usage_count}`);
2897
+ if (tier !== "pro" && status.trial) {
2898
+ const { uses_remaining, max_uses, days_remaining, is_expired } = status.trial;
2899
+ const used = max_uses - uses_remaining;
2900
+ lines.push(`Trial: ${used}/${max_uses} uses${is_expired ? " (expired)" : ` (${uses_remaining} remaining)`}`);
2901
+ if (!is_expired && days_remaining > 0) {
2902
+ lines.push(`Trial ends in ${days_remaining} day${days_remaining === 1 ? "" : "s"}`);
2903
+ }
2904
+ }
2905
+ if (tier === "pro" && status.subscription) {
2906
+ const end = status.subscription.current_period_end;
2907
+ if (status.subscription.is_lifetime) {
2908
+ lines.push("Lifetime access");
2909
+ } else if (isCanceling(status)) {
2910
+ const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2911
+ lines.push(`Cancels: ${date}`);
2912
+ } else if (end) {
2913
+ const date = new Date(end).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2914
+ lines.push(`Renews: ${date}`);
2915
+ }
2916
+ }
2917
+ return lines;
2918
+ }
2919
+
2920
+ // src/lib/browser-open.ts
2921
+ import { exec as exec2 } from "child_process";
2922
+ function editorBaseUrl() {
2923
+ return (process.env.QUICKPEEK_API_BASE || "https://quickpeek.co").replace(/\/$/, "");
2924
+ }
2925
+ function openInBrowser(url) {
2926
+ const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
2927
+ const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
2928
+ return new Promise((resolve10) => {
2929
+ exec2(command, () => resolve10());
2930
+ });
2931
+ }
2932
+
2933
+ // src/lib/plan-validation.ts
2934
+ var INVALID_TARGETS = [
2935
+ /\.png$/i,
2936
+ /\.jpg$/i,
2937
+ /\.gif$/i,
2938
+ /\.webp$/i,
2939
+ /\.svg$/i,
2940
+ /sprite_sheet/i,
2941
+ /downloaded/i
2942
+ ];
2943
+ var ACTIONS_REQUIRING_TARGET = ["click", "type", "select", "check", "drag"];
2944
+ function isFilePath(value) {
2945
+ if (!value) return false;
2946
+ return /\.\w{2,5}$/.test(value) && (/[/\\]/.test(value) || /^[a-zA-Z]:/.test(value));
2947
+ }
2948
+ function isInvalidTarget(target) {
2949
+ return INVALID_TARGETS.some((pattern) => pattern.test(target));
2950
+ }
2951
+ function validateStep(step) {
2952
+ if (!step?.action) {
2953
+ return "Missing action";
2954
+ }
2955
+ if (step.action === "navigate") {
2956
+ if (!step.target) return "Navigate requires a URL target";
2957
+ if (!step.target.startsWith("http://") && !step.target.startsWith("https://")) {
2958
+ return "Navigate target must be a valid URL";
2959
+ }
2960
+ }
2961
+ if (step.action === "media") {
2962
+ if (!step.mediaFile && !isFilePath(step.value)) {
2963
+ return "Media step requires a file (image or video)";
2964
+ }
2965
+ }
2966
+ if (step.action === "upload") {
2967
+ if (!step.mediaFile && !isFilePath(step.value)) {
2968
+ return "Upload step requires a file";
2969
+ }
2970
+ }
2971
+ if (ACTIONS_REQUIRING_TARGET.includes(step.action)) {
2972
+ if (!step.target) return `${step.action} requires a target`;
2973
+ if (isInvalidTarget(step.target)) return `Invalid target pattern: ${step.target}`;
2974
+ }
2975
+ return null;
2976
+ }
2977
+
2978
+ // src/lib/plan-file.ts
2979
+ var MAX_HANDOFF_URL_CHARS = 32e3;
2980
+ function serializePlan(plan) {
2981
+ return `${JSON.stringify(plan, null, 2)}
2982
+ `;
2983
+ }
2984
+ function parsePlanText(text) {
2985
+ let raw;
2986
+ try {
2987
+ raw = JSON.parse(text);
2988
+ } catch (e) {
2989
+ return { ok: false, error: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
2990
+ }
2991
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2992
+ return { ok: false, error: "A plan must be a JSON object." };
2993
+ }
2994
+ const candidate = raw;
2995
+ if (typeof candidate.url !== "string" || !candidate.url.trim()) {
2996
+ return { ok: false, error: "A plan needs a `url` to record." };
2997
+ }
2998
+ if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) {
2999
+ return { ok: false, error: "A plan needs at least one step." };
3000
+ }
3001
+ const steps = [];
3002
+ for (const [index, step] of candidate.steps.entries()) {
3003
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
3004
+ return { ok: false, error: `Step ${index + 1} is not an object.` };
3005
+ }
3006
+ const typed = step;
3007
+ if (typeof typed.action !== "string" || !typed.action.trim()) {
3008
+ return { ok: false, error: `Step ${index + 1} has no action.` };
3009
+ }
3010
+ const failure = validateStep(typed);
3011
+ if (failure) return { ok: false, error: `Step ${index + 1}: ${failure}` };
3012
+ steps.push({ ...typed, id: typed.id ?? index + 1 });
3013
+ }
3014
+ return {
3015
+ ok: true,
3016
+ plan: {
3017
+ url: candidate.url,
3018
+ title: candidate.title ?? "",
3019
+ description: candidate.description ?? "",
3020
+ ...candidate.lang ? { lang: candidate.lang } : {},
3021
+ steps
3022
+ }
3023
+ };
3024
+ }
3025
+ function toBase64Url(text) {
3026
+ const bytes = new TextEncoder().encode(text);
3027
+ const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
3028
+ const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(text, "utf8").toString("base64");
3029
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3030
+ }
3031
+ function encodePlan(plan) {
3032
+ return toBase64Url(serializePlan(plan));
3033
+ }
3034
+ function planEditorUrl(baseUrl, plan) {
3035
+ const url = `${baseUrl.replace(/\/$/, "")}/edit#plan=${encodePlan(plan)}`;
3036
+ return url.length <= MAX_HANDOFF_URL_CHARS ? url : null;
3037
+ }
3038
+
3039
+ // src/utils/ui.ts
3040
+ import { resolve as resolve4 } from "path";
3041
+ import { createInterface } from "readline";
2409
3042
  import chalk2 from "chalk";
2410
3043
  import ora from "ora";
2411
3044
  var INDENT = " ";
@@ -2413,6 +3046,9 @@ var autoYes = false;
2413
3046
  function setAutoYes(value) {
2414
3047
  autoYes = value;
2415
3048
  }
3049
+ function isAutoYes() {
3050
+ return autoYes;
3051
+ }
2416
3052
  function task(text) {
2417
3053
  return ora({ text, prefixText: INDENT, spinner: "dots" }).start();
2418
3054
  }
@@ -2446,7 +3082,7 @@ function promptInput(prompt2) {
2446
3082
  var _isWSL = process.platform === "linux" && process.env.WSL_DISTRO_NAME;
2447
3083
  function formatClickablePath(filePath) {
2448
3084
  const absolutePath = resolve4(filePath);
2449
- if (_isWSL) {
3085
+ if (_isWSL || process.platform !== "win32") {
2450
3086
  return absolutePath;
2451
3087
  }
2452
3088
  let formatted = absolutePath;
@@ -2462,12 +3098,12 @@ function formatDuration(ms) {
2462
3098
  return `${mins}:${secs.toString().padStart(2, "0")}`;
2463
3099
  }
2464
3100
 
2465
- // src/commands/phases/auth-phase.ts
3101
+ // src/commands/utils/record-helpers.ts
3102
+ import chalk6 from "chalk";
2466
3103
  init_lib();
2467
- import chalk8 from "chalk";
2468
3104
 
2469
3105
  // src/plan.ts
2470
- import { mkdir as mkdir5, readFile as readFile3, writeFile as writeFile6 } from "fs/promises";
3106
+ import { mkdir as mkdir6, readFile as readFile3, writeFile as writeFile6 } from "fs/promises";
2471
3107
  import { join as join9 } from "path";
2472
3108
  import chalk5 from "chalk";
2473
3109
  import { chromium as chromium4 } from "playwright";
@@ -2478,19 +3114,11 @@ import { createInterface as createInterface2 } from "readline";
2478
3114
  import chalk4 from "chalk";
2479
3115
 
2480
3116
  // src/utils/browser-auth.ts
2481
- init_SparkStripe_feature_gating();
2482
3117
  import { createServer } from "http";
2483
3118
  import { URL as URL2 } from "url";
2484
3119
  import chalk3 from "chalk";
3120
+ init_SparkStripe_feature_gating();
2485
3121
  var TIMEOUT_MS = 12e4;
2486
- async function openBrowser(url) {
2487
- const { exec: exec2 } = await import("child_process");
2488
- const isWSL2 = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME);
2489
- const command = isWSL2 ? `cmd.exe /c start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
2490
- return new Promise((resolve10) => {
2491
- exec2(command, () => resolve10());
2492
- });
2493
- }
2494
3122
  async function browserLogin() {
2495
3123
  return new Promise((resolve10) => {
2496
3124
  let server;
@@ -2544,7 +3172,7 @@ async function browserLogin() {
2544
3172
  console.log(chalk3.dim(` ${url}`));
2545
3173
  console.log();
2546
3174
  console.log(chalk3.dim(" Waiting for sign-in (press Ctrl+C to cancel)..."));
2547
- openBrowser(url);
3175
+ void openInBrowser(url);
2548
3176
  });
2549
3177
  timer = setTimeout(() => {
2550
3178
  console.log();
@@ -2554,302 +3182,293 @@ async function browserLogin() {
2554
3182
  });
2555
3183
  }
2556
3184
 
2557
- // src/ai/client.ts
2558
- var lastRateLimitInfo = null;
2559
- function getLastRateLimitInfo() {
2560
- return lastRateLimitInfo;
2561
- }
2562
- function prompt(question) {
2563
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2564
- return new Promise((resolve10) => {
2565
- rl.question(question, (answer) => {
2566
- rl.close();
2567
- resolve10(answer.trim());
2568
- });
2569
- });
2570
- }
2571
- async function setupUserEmail() {
2572
- console.log();
2573
- console.log(chalk4.yellow(" \u2709 Account Setup"));
2574
- console.log(chalk4.dim(" \u2500".repeat(20)));
2575
- console.log();
2576
- console.log(chalk4.white(" Sign in with your Google account to track usage"));
2577
- console.log(chalk4.white(" and unlock Pro features."));
2578
- const browserEmail = await browserLogin();
2579
- if (browserEmail) {
2580
- console.log();
2581
- console.log(chalk4.green(` \u2713 Signed in as ${browserEmail}`));
2582
- console.log();
2583
- return browserEmail;
2584
- }
2585
- console.log();
2586
- console.log(chalk4.dim(" Or enter your email manually:"));
2587
- const email = await prompt(chalk4.white(" Email: "));
2588
- if (!email) {
2589
- console.log();
2590
- console.log(chalk4.red(" No email provided. Exiting."));
2591
- process.exit(1);
2592
- }
2593
- if (!email.includes("@") || !email.includes(".")) {
2594
- console.log();
2595
- console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
2596
- const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
2597
- if (proceed.toLowerCase() !== "y") {
2598
- process.exit(1);
2599
- }
2600
- }
2601
- console.log();
2602
- console.log(chalk4.green(" \u2713 Email registered"));
2603
- console.log();
2604
- return email;
2605
- }
2606
- async function callGroq(systemPrompt, userPrompt, logError2, userEmail, tier = "free") {
2607
- const result = process.env.SPARK_AI_KEY ? await callAI(systemPrompt, userPrompt, tier) : await callAIViaRelay(systemPrompt, userPrompt, userEmail ?? "", tier);
2608
- if (!result.success) {
2609
- await logError2("AI API", new Error(result.error));
2610
- if (result.isRateLimit) {
2611
- console.log();
2612
- console.log(chalk4.red(" \u26A0 Rate Limit Reached (after 3 retries)"));
2613
- console.log();
2614
- console.log(chalk4.dim(" Spark AI daily limit exceeded. You may have hit your quota."));
2615
- console.log(chalk4.dim(" Options:"));
2616
- if (result.retryAfterSeconds) {
2617
- const minutes = Math.ceil(result.retryAfterSeconds / 60);
2618
- console.log(chalk4.dim(` \u2022 Wait ${minutes} minutes until quota resets`));
2619
- } else {
2620
- console.log(chalk4.dim(" \u2022 Wait until tomorrow (limits reset at midnight UTC)"));
2621
- }
2622
- console.log(chalk4.dim(" \u2022 Check your usage at: https://sparkbrain.app/dashboard"));
2623
- console.log(chalk4.dim(" \u2022 Upgrade to a higher tier for more requests"));
2624
- console.log();
2625
- process.exit(1);
2626
- }
2627
- if (result.isAuthError) {
2628
- console.log();
2629
- console.log(chalk4.red(" \u26A0 Authentication Error"));
2630
- console.log();
2631
- console.log(chalk4.dim(` ${result.error}`));
2632
- console.log();
2633
- process.exit(1);
3185
+ // src/utils/logging.ts
3186
+ import { appendFile, mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
3187
+ import { dirname as dirname2, join as join8 } from "path";
3188
+ var LOG_FILE_NAME = "quickpeek.log";
3189
+ var logFileInitialized = false;
3190
+ var logFilePath = LOG_FILE_NAME;
3191
+ async function writeLogEntry(entry) {
3192
+ try {
3193
+ if (!logFileInitialized) {
3194
+ await writeFile5(logFilePath, entry);
3195
+ logFileInitialized = true;
3196
+ } else {
3197
+ await appendFile(logFilePath, entry);
2634
3198
  }
2635
- console.log();
2636
- console.log(chalk4.red(" \u26A0 AI Error"));
2637
- console.log();
2638
- const firstLine = result.error.split("\n")[0] ?? result.error;
2639
- console.log(chalk4.dim(` ${firstLine.slice(0, 80)}`));
2640
- console.log();
2641
- process.exit(1);
3199
+ } catch {
2642
3200
  }
2643
- lastRateLimitInfo = result.limits;
2644
- return result.content;
2645
3201
  }
3202
+ async function setLogFilePath(outputDir) {
3203
+ logFilePath = join8(outputDir, LOG_FILE_NAME);
3204
+ const header2 = `QuickPeek Log - ${(/* @__PURE__ */ new Date()).toISOString()}
3205
+ ${"=".repeat(60)}
2646
3206
 
2647
- // src/browser/launch.ts
2648
- import { mkdir as mkdir4 } from "fs/promises";
2649
- import { chromium as chromium3 } from "playwright";
2650
-
2651
- // src/browser/wsl.ts
2652
- import { execSync, spawn as spawn2 } from "child_process";
2653
- import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
2654
- import { request } from "http";
2655
- import { chromium as chromium2 } from "playwright";
2656
- function isWSL() {
2657
- if (process.platform !== "linux") return false;
3207
+ `;
2658
3208
  try {
2659
- const r = execSync("uname -r", { encoding: "utf-8" }).toLowerCase();
2660
- return r.includes("microsoft") || r.includes("wsl");
3209
+ await mkdir5(dirname2(logFilePath), { recursive: true });
3210
+ await writeFile5(logFilePath, header2);
3211
+ logFileInitialized = true;
2661
3212
  } catch {
2662
- return false;
2663
3213
  }
2664
3214
  }
2665
- function tryEnableWSLInterop() {
2666
- return existsSync6("/proc/sys/fs/binfmt_misc/WSLInterop");
3215
+ async function logInfo(context, message) {
3216
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [INFO] [${context}] ${message}
3217
+ `);
2667
3218
  }
2668
- function wslToWindowsPath(wslPath) {
2669
- if (!wslPath) return wslPath;
2670
- const m = wslPath.match(/^\/mnt\/([a-z])\/(.*)/);
2671
- if (!m) return wslPath;
2672
- return `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
3219
+ async function logDebug(context, message) {
3220
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] [${context}] ${message}
3221
+ `);
2673
3222
  }
2674
- function findExe(whereExeQuery, ...hardcoded) {
2675
- for (const p of hardcoded) if (existsSync6(p)) return p;
2676
- try {
2677
- const line = execSync(`where.exe ${whereExeQuery}`, { encoding: "utf-8", timeout: 3e3 }).split("\n")[0]?.trim();
2678
- if (line) return line.replace(/^([A-Za-z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
2679
- } catch {
3223
+ async function logError(context, error) {
3224
+ let errorMsg;
3225
+ if (error instanceof Error) {
3226
+ errorMsg = `${error.message}
3227
+ ${error.stack}`;
3228
+ } else if (typeof error === "object" && error !== null) {
3229
+ errorMsg = JSON.stringify(error, null, 2);
3230
+ } else {
3231
+ errorMsg = String(error);
2680
3232
  }
2681
- return null;
3233
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [ERROR] [${context}]
3234
+ ${errorMsg}
3235
+
3236
+ `);
2682
3237
  }
2683
- var findWindowsChrome = () => findExe(
2684
- "chrome.exe",
2685
- "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
2686
- "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
2687
- );
2688
- var findWindowsNode = () => findExe("node.exe");
2689
- function getWindowsHostIP() {
2690
- try {
2691
- const m = execSync("ip route show default", { encoding: "utf-8", timeout: 2e3 }).match(
2692
- /default via (\d+\.\d+\.\d+\.\d+)/
3238
+ async function logStep(stepNum, action, details) {
3239
+ const detailsStr = Object.entries(details).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ");
3240
+ await writeLogEntry(`[${(/* @__PURE__ */ new Date()).toISOString()}] [STEP ${stepNum}] ${action} | ${detailsStr}
3241
+ `);
3242
+ }
3243
+
3244
+ // src/ai/mcp-context.ts
3245
+ import { AsyncLocalStorage } from "async_hooks";
3246
+ var DelegatedGenerationError = class extends Error {
3247
+ tool;
3248
+ step;
3249
+ /** The system instructions the call was about to send. */
3250
+ system;
3251
+ /** The user-side prompt. */
3252
+ prompt;
3253
+ constructor(tool, step, system, prompt2) {
3254
+ super(
3255
+ `Generation for "${tool}" (${step}) belongs to the calling model, not Groq. The tool should hand back this prompt and accept the result as a parameter.`
2693
3256
  );
2694
- if (m?.[1]) return m[1];
2695
- } catch {
2696
- }
2697
- try {
2698
- const m = readFileSync2("/etc/resolv.conf", "utf-8").match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
2699
- if (m?.[1]) return m[1];
2700
- } catch {
3257
+ this.name = "DelegatedGenerationError";
3258
+ this.tool = tool;
3259
+ this.step = step;
3260
+ this.system = system;
3261
+ this.prompt = prompt2;
2701
3262
  }
2702
- return "127.0.0.1";
3263
+ };
3264
+ var mcpCall = new AsyncLocalStorage();
3265
+ function runInMcpTool(tool, fn, answers = {}) {
3266
+ return mcpCall.run({ tool, answers, pending: [] }, fn);
2703
3267
  }
2704
- function startRelay(nodePath, windowsHost, targetPort) {
2705
- const script = `var n=require('net'),s=n.createServer(function(c){var r=n.createConnection(${targetPort},'127.0.0.1');c.pipe(r);r.pipe(c);r.on('error',function(){c.destroy()});c.on('error',function(){r.destroy()});});s.listen(0,'${windowsHost}',function(){process.stdout.write('PORT:'+s.address().port+'\\n')});`;
2706
- return new Promise((resolve10, reject) => {
2707
- const proc = spawn2(nodePath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"], detached: true });
2708
- const timer = setTimeout(() => reject(new Error("relay startup timed out")), 6e3);
2709
- let buf = "";
2710
- proc.stdout.on("data", (d) => {
2711
- buf += d.toString();
2712
- const m = buf.match(/PORT:(\d+)/);
2713
- if (m) {
2714
- clearTimeout(timer);
2715
- resolve10({ proc, port: +m[1] });
2716
- }
2717
- });
2718
- proc.on("error", (e) => {
2719
- clearTimeout(timer);
2720
- reject(e);
2721
- });
2722
- });
3268
+ function currentMcpTool() {
3269
+ return mcpCall.getStore()?.tool ?? null;
2723
3270
  }
2724
- function waitForChrome(host, port) {
2725
- return new Promise((resolve10, reject) => {
2726
- const deadline = Date.now() + 2e4;
2727
- const poll = () => {
2728
- if (Date.now() > deadline) {
2729
- reject(new Error("Chrome debug port did not open within 20 s"));
2730
- return;
2731
- }
2732
- const req = request({ host, port, path: "/json/version", method: "GET" }, (res) => {
2733
- res.resume();
2734
- resolve10();
2735
- });
2736
- req.on("error", () => setTimeout(poll, 600));
2737
- req.setTimeout(1e3, () => {
2738
- req.destroy();
2739
- setTimeout(poll, 600);
2740
- });
2741
- req.end();
2742
- };
2743
- poll();
3271
+ function suppliedAnswer(step) {
3272
+ return mcpCall.getStore()?.answers?.[step];
3273
+ }
3274
+ function recordDelegation(e) {
3275
+ const store = mcpCall.getStore();
3276
+ if (store && !store.pending.some((p) => p.step === e.step)) store.pending.push(e);
3277
+ }
3278
+ function pendingDelegations() {
3279
+ return mcpCall.getStore()?.pending ?? [];
3280
+ }
3281
+
3282
+ // src/ai/client.ts
3283
+ var lastRateLimitInfo = null;
3284
+ function wrap(text, width) {
3285
+ const lines = [];
3286
+ for (const paragraph of text.split("\n")) {
3287
+ let current = "";
3288
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
3289
+ if (current && current.length + word.length + 1 > width) {
3290
+ lines.push(current);
3291
+ current = word;
3292
+ } else {
3293
+ current = current ? `${current} ${word}` : word;
3294
+ }
3295
+ }
3296
+ lines.push(current);
3297
+ }
3298
+ return lines.filter((l) => l.length > 0);
3299
+ }
3300
+ function diagnose(status, host) {
3301
+ const where = host ?? "the AI service";
3302
+ if (status === void 0) return `Could not reach ${where}. Check your network, then retry.`;
3303
+ if (status === 401 || status === 403) return `${where} rejected the credentials. Run qp login and retry.`;
3304
+ if (status === 413) return `The prompt was too large for ${where}. Shorten the script or the page.`;
3305
+ if (status === 429) return `${where} is rate limiting. Wait for the quota to reset, then retry.`;
3306
+ if (status === 502 || status === 503 || status === 504) {
3307
+ return `${where} could not reach the AI backend (upstream outage). Retry in a few minutes.`;
3308
+ }
3309
+ if (status >= 500) return `${where} failed internally. Retry in a few minutes; if it persists, report this.`;
3310
+ return `${where} rejected the request. See the body above.`;
3311
+ }
3312
+ function getLastRateLimitInfo() {
3313
+ return lastRateLimitInfo;
3314
+ }
3315
+ function prompt(question) {
3316
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
3317
+ return new Promise((resolve10) => {
3318
+ rl.question(question, (answer) => {
3319
+ rl.close();
3320
+ resolve10(answer.trim());
3321
+ });
2744
3322
  });
2745
3323
  }
2746
- async function launchWindowsChrome(extensionPath, userDataDir, options = {}) {
2747
- const debugPort = options.debugPort ?? 9222;
2748
- const chromePath = findWindowsChrome();
2749
- if (!chromePath) throw new Error("Windows Chrome not found. Install Google Chrome for Windows.");
2750
- const nodePath = findWindowsNode();
2751
- if (!nodePath) throw new Error("Windows Node.js not found. Install Node.js for Windows.");
2752
- const winUserData = wslToWindowsPath(userDataDir);
2753
- const winExtPath = extensionPath ? wslToWindowsPath(extensionPath) : "";
2754
- const chromeProc = spawn2(
2755
- chromePath,
2756
- [
2757
- `--remote-debugging-port=${debugPort}`,
2758
- `--user-data-dir=${winUserData}`,
2759
- ...winExtPath ? [`--disable-extensions-except=${winExtPath}`, `--load-extension=${winExtPath}`] : [],
2760
- "--no-first-run",
2761
- "--no-default-browser-check",
2762
- "--disable-background-networking",
2763
- "--disable-sync",
2764
- "--disable-features=TranslateUI",
2765
- options.startMaximized ? "--start-maximized" : "--start-minimized"
2766
- ],
2767
- { stdio: "ignore", detached: true }
2768
- );
2769
- chromeProc.unref();
2770
- const windowsHost = getWindowsHostIP();
2771
- const { proc: relay, port: proxyPort } = await startRelay(nodePath, windowsHost, debugPort);
2772
- try {
2773
- await waitForChrome(windowsHost, proxyPort);
2774
- const browser = await chromium2.connectOverCDP(`http://${windowsHost}:${proxyPort}`, { timeout: 5e3 });
2775
- return {
2776
- browser,
2777
- cleanup: async () => {
2778
- try {
2779
- await browser.close();
2780
- } catch {
2781
- }
2782
- relay.kill();
2783
- }
2784
- };
2785
- } catch (err) {
2786
- relay.kill();
2787
- throw err;
3324
+ async function setupUserEmail() {
3325
+ console.log();
3326
+ console.log(chalk4.yellow(" \u2709 Account Setup"));
3327
+ console.log(chalk4.dim(" \u2500".repeat(20)));
3328
+ console.log();
3329
+ console.log(chalk4.white(" Sign in with your Google account to track usage"));
3330
+ console.log(chalk4.white(" and unlock Pro features."));
3331
+ const browserEmail = await browserLogin();
3332
+ if (browserEmail) {
3333
+ console.log();
3334
+ console.log(chalk4.green(` \u2713 Signed in as ${browserEmail}`));
3335
+ console.log();
3336
+ return browserEmail;
3337
+ }
3338
+ console.log();
3339
+ console.log(chalk4.dim(" Or enter your email manually:"));
3340
+ const email = await prompt(chalk4.white(" Email: "));
3341
+ if (!email) {
3342
+ console.log();
3343
+ console.log(chalk4.red(" No email provided. Exiting."));
3344
+ process.exit(1);
3345
+ }
3346
+ if (!email.includes("@") || !email.includes(".")) {
3347
+ console.log();
3348
+ console.log(chalk4.yellow(" \u26A0 That doesn't look like a valid email address."));
3349
+ const proceed = await prompt(chalk4.white(" Continue anyway? ") + chalk4.dim("[y/N] "));
3350
+ if (proceed.toLowerCase() !== "y") {
3351
+ process.exit(1);
3352
+ }
2788
3353
  }
3354
+ console.log();
3355
+ console.log(chalk4.green(" \u2713 Email registered"));
3356
+ console.log();
3357
+ return email;
2789
3358
  }
2790
-
2791
- // src/browser/launch.ts
2792
- async function launchBrowserContext(config, userDataDir, recordVideo, storageStatePath) {
2793
- const extensionPath = config.browser?.extension;
2794
- const viewport = { width: config.video.width, height: config.video.height };
2795
- const videoOpts = recordVideo ? { recordVideo } : {};
2796
- const authOpts = storageStatePath ? { storageState: storageStatePath } : {};
2797
- let context;
2798
- let browser = null;
2799
- let chromeSession = null;
2800
- if (extensionPath) {
2801
- await mkdir4(userDataDir, { recursive: true });
2802
- const persistentArgs = [
2803
- `--disable-extensions-except=${extensionPath}`,
2804
- `--load-extension=${extensionPath}`,
2805
- "--window-position=-32000,-32000",
2806
- "--window-size=1,1"
2807
- ];
2808
- const useWindowsChrome = isWSL() && tryEnableWSLInterop();
2809
- if (useWindowsChrome) {
2810
- try {
2811
- chromeSession = await launchWindowsChrome(extensionPath, userDataDir, { viewport, ...videoOpts });
2812
- const contexts = chromeSession.browser.contexts();
2813
- context = contexts[0] || await chromeSession.browser.newContext({ viewport, ...videoOpts, acceptDownloads: true });
2814
- } catch {
2815
- context = await chromium3.launchPersistentContext(userDataDir, {
2816
- headless: false,
2817
- args: persistentArgs,
2818
- viewport,
2819
- ...videoOpts,
2820
- acceptDownloads: true
2821
- });
3359
+ async function callGroq(systemPrompt, userPrompt, logError2, userEmail, tier = "free", step = "plan") {
3360
+ const mcpTool = currentMcpTool();
3361
+ if (mcpTool) {
3362
+ const answered = suppliedAnswer(step);
3363
+ if (typeof answered === "string") return answered;
3364
+ const refusal = new DelegatedGenerationError(mcpTool, step, systemPrompt, userPrompt);
3365
+ recordDelegation(refusal);
3366
+ throw refusal;
3367
+ }
3368
+ const onRetry = ({ attempt, status, delayMs, host }) => {
3369
+ console.log(
3370
+ chalk4.dim(
3371
+ `
3372
+ ${host} returned ${status}; retrying in ${Math.round(delayMs / 1e3)}s (attempt ${attempt + 1} of 3)`
3373
+ )
3374
+ );
3375
+ };
3376
+ const chatOpts = { onRetry, backoffMs: CLI_BACKOFF_MS };
3377
+ const result = process.env.SPARK_AI_KEY ? await callAI(systemPrompt, userPrompt, tier, chatOpts) : await callAIViaRelay(systemPrompt, userPrompt, userEmail ?? "", tier, chatOpts);
3378
+ if (!result.success) {
3379
+ await logError2("AI API", new Error(result.detail ? `${result.error}
3380
+ Body: ${result.detail}` : result.error));
3381
+ if (result.isRateLimit) {
3382
+ console.log();
3383
+ console.log(chalk4.red(" \u26A0 Rate Limit Reached (after 3 retries)"));
3384
+ console.log();
3385
+ console.log(chalk4.dim(` ${result.error}`));
3386
+ console.log(chalk4.dim(" Options:"));
3387
+ if (result.retryAfterSeconds) {
3388
+ const minutes = Math.ceil(result.retryAfterSeconds / 60);
3389
+ console.log(chalk4.dim(` \u2022 Wait ${minutes} minutes until quota resets`));
3390
+ } else {
3391
+ console.log(chalk4.dim(" \u2022 Wait until tomorrow (limits reset at midnight UTC)"));
2822
3392
  }
2823
- } else {
2824
- context = await chromium3.launchPersistentContext(userDataDir, {
2825
- headless: false,
2826
- args: persistentArgs,
2827
- viewport,
2828
- ...videoOpts,
2829
- acceptDownloads: true
2830
- });
3393
+ console.log(chalk4.dim(" \u2022 Check your usage at: https://sparkbrain.app/dashboard"));
3394
+ console.log(chalk4.dim(" \u2022 Upgrade to a higher tier for more requests"));
3395
+ console.log();
3396
+ process.exit(1);
2831
3397
  }
2832
- } else {
2833
- browser = await chromium3.launch({ headless: true });
2834
- context = await browser.newContext({ viewport, ...videoOpts, ...authOpts, acceptDownloads: true });
3398
+ if (result.isAuthError) {
3399
+ console.log();
3400
+ console.log(chalk4.red(` \u26A0 Authentication Error${result.status ? ` (HTTP ${result.status})` : ""}`));
3401
+ console.log();
3402
+ for (const line of wrap(result.error, 76)) {
3403
+ console.log(chalk4.dim(` ${line}`));
3404
+ }
3405
+ console.log();
3406
+ console.log(chalk4.dim(` ${diagnose(result.status, result.host)}`));
3407
+ console.log();
3408
+ process.exit(1);
3409
+ }
3410
+ console.log();
3411
+ console.log(chalk4.red(` \u26A0 AI Error${result.status ? ` (HTTP ${result.status})` : ""}`));
3412
+ console.log();
3413
+ for (const line of wrap(result.error, 76)) {
3414
+ console.log(chalk4.dim(` ${line}`));
3415
+ }
3416
+ if (result.detail && !result.error.includes(result.detail)) {
3417
+ console.log();
3418
+ for (const line of wrap(result.detail, 76).slice(0, 6)) {
3419
+ console.log(chalk4.dim(` ${line}`));
3420
+ }
3421
+ }
3422
+ console.log();
3423
+ console.log(chalk4.dim(` ${diagnose(result.status, result.host)}`));
3424
+ console.log(chalk4.dim(` Full response body: ${logFilePath}`));
3425
+ console.log();
3426
+ process.exit(1);
2835
3427
  }
2836
- return { context, browser, chromeSession };
3428
+ lastRateLimitInfo = result.limits;
3429
+ return result.content;
2837
3430
  }
2838
- async function cleanupBrowserSession(session) {
2839
- if (session.chromeSession) {
2840
- await session.chromeSession.cleanup();
2841
- } else {
2842
- await session.context.close();
2843
- if (session.browser) await session.browser.close();
3431
+
3432
+ // src/ai/json-plan.ts
3433
+ init_lib();
3434
+ init_utils();
3435
+ var JSON_FIX_SYSTEM_PROMPT = "You fix malformed JSON. Return ONLY valid JSON, no explanation.";
3436
+ var PLAN_SHAPE = `The JSON should have: url, title, description, steps array.
3437
+ Each step has: id (number), action, caption, and optionally target, value.`;
3438
+ var jsonFixUserPrompt = (content, shape) => `Fix this malformed JSON. Return ONLY valid JSON, no explanation.
3439
+ ${shape}
3440
+
3441
+ Malformed content:
3442
+ ${content}`;
3443
+ var noopTask2 = () => ({ succeed: () => {
3444
+ }, fail: () => {
3445
+ } });
3446
+ async function parseAIJsonWithRepair(content, opts) {
3447
+ const { logError: logError2, userEmail, tier = "free", shape = PLAN_SHAPE, context = "JSON Fix" } = opts;
3448
+ const direct = parseAIJson(content);
3449
+ if (direct) return direct.value;
3450
+ const makeTask = opts.task ?? noopTask2;
3451
+ const fixTask = makeTask("Fixing malformed JSON");
3452
+ try {
3453
+ const fixed = await callGroq(JSON_FIX_SYSTEM_PROMPT, jsonFixUserPrompt(content, shape), logError2, userEmail, tier);
3454
+ const repaired = parseAIJson(fixed);
3455
+ if (!repaired) throw new Error("No usable JSON in fix");
3456
+ fixTask.succeed("JSON fixed by AI");
3457
+ return repaired.value;
3458
+ } catch (err) {
3459
+ await logError2(context, err);
3460
+ fixTask.fail("Could not fix JSON");
3461
+ throw new Error(`Malformed JSON: ${getErrorMessage(err)}`);
2844
3462
  }
2845
3463
  }
2846
3464
 
2847
3465
  // src/plan.ts
2848
3466
  init_lib();
3467
+ init_json_repair();
2849
3468
  async function crawlExtension(url, extensionPath, workDir, task2) {
2850
3469
  const browserTask = task2("Launching browser with extension");
2851
3470
  const userDataDir = join9(workDir, ".browser-data");
2852
- await mkdir5(userDataDir, { recursive: true });
3471
+ await mkdir6(userDataDir, { recursive: true });
2853
3472
  let context;
2854
3473
  let chromeSession = null;
2855
3474
  const useWindowsChrome = isWSL() && tryEnableWSLInterop();
@@ -2934,7 +3553,7 @@ async function crawlExtension(url, extensionPath, workDir, task2) {
2934
3553
  else if (el.type === "select" || el.type === "select-one") action = "select";
2935
3554
  else if (el.type === "checkbox") action = "check";
2936
3555
  else if (["text", "email", "password", "number", "search", "url", "tel"].includes(el.type)) action = "type";
2937
- const selector = el.id ? `#${el.id}` : el.name ? `[name="${el.name}"]` : `text="${el.text}"`;
3556
+ const selector = el.id ? idSelector(el.id) : el.name ? `[name="${el.name}"]` : `text="${el.text}"`;
2938
3557
  return {
2939
3558
  type: el.type === "button" || el.type === "link" ? el.type : "input",
2940
3559
  text: el.text,
@@ -2946,43 +3565,19 @@ async function crawlExtension(url, extensionPath, workDir, task2) {
2946
3565
  crawlTask.succeed(`Found ${elements.length} interactive elements`);
2947
3566
  return { title, elements };
2948
3567
  }
2949
- var JSON_FIX_SYSTEM_PROMPT = "You fix malformed JSON. Return ONLY valid JSON, no explanation.";
2950
- var jsonFixUserPrompt = (content) => `Fix this malformed JSON plan. Return ONLY valid JSON, no explanation.
2951
- The JSON should have: url, title, description, steps array.
2952
- Each step has: id (number), action, caption, and optionally target, value.
2953
-
2954
- Malformed content:
2955
- ${content}`;
2956
3568
  async function parsePlanJson(content, task2, logError2, userEmail, tier = "free") {
2957
- try {
2958
- return JSON.parse(content);
2959
- } catch {
2960
- const fixTask = task2("Fixing malformed JSON");
2961
- try {
2962
- const fixed = await callGroq(JSON_FIX_SYSTEM_PROMPT, jsonFixUserPrompt(content), logError2, userEmail, tier);
2963
- const jsonMatch = fixed.match(/\{[\s\S]*\}/);
2964
- if (!jsonMatch) throw new Error("No JSON in fix");
2965
- const plan = JSON.parse(jsonMatch[0]);
2966
- fixTask.succeed("JSON fixed by AI");
2967
- return plan;
2968
- } catch (err) {
2969
- await logError2("JSON Fix", err);
2970
- fixTask.fail("Could not fix JSON");
2971
- throw new Error(`Malformed JSON: ${getErrorMessage(err)}`);
2972
- }
2973
- }
3569
+ return parseAIJsonWithRepair(content, { logError: logError2, task: task2, userEmail, tier });
2974
3570
  }
2975
- async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError2, userEmail, extensionPath, workDir, tier = "free") {
2976
- const { title, elements } = extensionPath && workDir ? await crawlExtension(url, extensionPath, workDir, task2) : await crawlPage(url, task2, findAuthState(url));
3571
+ async function crawlAndAnalyze(url, lang, description, maxSteps, task2, logError2, userEmail, extensionPath, workDir, tier = "free", viewport) {
3572
+ const { title, elements, scrollable } = extensionPath && workDir ? { ...await crawlExtension(url, extensionPath, workDir, task2), scrollable: void 0 } : await crawlPage(url, task2, findAuthState(url), viewport);
2977
3573
  const aiTask = task2("Generating plan with AI");
2978
3574
  const langName = getLanguageName(lang);
2979
3575
  const systemPrompt = buildSystemPrompt(maxSteps);
2980
- const userPrompt = buildUserPrompt(url, title, elements, langName, description, maxSteps);
3576
+ const userPrompt = buildUserPrompt(url, title, elements, langName, description, maxSteps, scrollable);
2981
3577
  try {
2982
- const content = await callGroq(systemPrompt, userPrompt, logError2, userEmail, tier);
2983
- const jsonMatch = content.match(/\{[\s\S]*\}/);
2984
- if (!jsonMatch) throw new Error("No JSON found in response");
2985
- const plan = await parsePlanJson(jsonMatch[0], task2, logError2, userEmail, tier);
3578
+ const content = await callGroq(systemPrompt, userPrompt, logError2, userEmail, tier, "plan");
3579
+ const plan = await parsePlanJson(content, task2, logError2, userEmail, tier);
3580
+ if (!Array.isArray(plan.steps)) throw new Error("AI response has no steps array");
2986
3581
  if (lang !== void 0) plan.lang = lang;
2987
3582
  plan.steps = plan.steps.map(({ duration, ...s }, i) => ({
2988
3583
  ...s,
@@ -3006,100 +3601,41 @@ async function validateAndFixPlan(planPath, task2, logError2, userEmail, tier =
3006
3601
  }
3007
3602
  const renumberTask = task2("Validating plan structure");
3008
3603
  const originalCount = plan.steps.length;
3009
- const INVALID_TARGETS = [/\.png$/i, /\.jpg$/i, /\.gif$/i, /\.webp$/i, /\.svg$/i, /sprite_sheet/i, /downloaded/i];
3010
- const isValidStep = (s) => {
3011
- if (!s?.action) return false;
3012
- if (s.action === "navigate") {
3013
- if (!s.target) return false;
3014
- if (!s.target.startsWith("http://") && !s.target.startsWith("https://")) return false;
3015
- }
3016
- if (["click", "type", "select", "check", "upload", "drag"].includes(s.action)) {
3017
- if (!s.target) return false;
3018
- if (INVALID_TARGETS.some((pattern) => pattern.test(s.target))) return false;
3019
- }
3020
- if (s.action === "upload") {
3021
- if (!s.target?.includes("file") && !s.target?.includes("input")) return false;
3022
- }
3023
- return true;
3024
- };
3025
- plan.steps = plan.steps.filter(isValidStep).map(({ duration, ...step }, i) => ({
3026
- ...step,
3027
- id: i + 1,
3028
- // Ensure upload steps have a value placeholder
3029
- ...step.action === "upload" && !step.value ? { value: "[file]" } : {}
3030
- }));
3031
- if (plan.steps.length === 0) {
3032
- renumberTask.fail("No valid steps");
3033
- throw new Error("Plan has no valid steps");
3034
- }
3035
- await writeFile6(planPath, JSON.stringify(plan, null, 2));
3036
- const removed = originalCount - plan.steps.length;
3037
- renumberTask.succeed(
3038
- removed > 0 ? `${plan.steps.length} steps (${removed} removed)` : `${plan.steps.length} steps validated`
3039
- );
3040
- return plan;
3041
- }
3042
-
3043
- // src/commands/phases/auth-phase.ts
3044
- init_config_loader();
3045
-
3046
- // src/commands/utils/auth-helpers.ts
3047
- init_lib();
3048
- import chalk6 from "chalk";
3049
- async function handleRegistrationGate() {
3050
- console.log();
3051
- console.log(chalk6.yellow(" \u{1F389} You've made 5 free videos!"));
3052
- console.log(chalk6.white(" Sign in to continue creating videos."));
3053
- console.log();
3054
- console.log(chalk6.dim(" Benefits of signing in:"));
3055
- console.log(chalk6.dim(" \u2022 20 more videos per month"));
3056
- console.log(chalk6.dim(" \u2022 Keep your video history"));
3057
- console.log(chalk6.dim(" \u2022 Upgrade to Pro anytime"));
3058
- let email = await browserLogin();
3059
- if (!email) {
3060
- console.log();
3061
- email = await promptInput("Email: ");
3062
- }
3063
- if (!email || !email.includes("@")) {
3064
- console.log(chalk6.red(" Invalid email. Run qp again to try again."));
3065
- return null;
3066
- }
3067
- const registerTask = task("Sending verification email");
3068
- const result = await registerFreeUser(email);
3069
- if (!result.success) {
3070
- registerTask.fail(result.error || "Registration failed");
3071
- return null;
3072
- }
3073
- if (result.alreadyVerified) {
3074
- registerTask.succeed("Already verified!");
3075
- return { email, verified: true };
3076
- }
3077
- registerTask.succeed("Check your email!");
3078
- console.log();
3079
- console.log(chalk6.cyan(" \u{1F4E7} Verification email sent to:"));
3080
- console.log(chalk6.white(` ${email}`));
3081
- console.log();
3082
- console.log(chalk6.dim(" Click the link in the email, then press Enter to continue..."));
3083
- await promptInput("");
3084
- const verifyTask = task("Checking verification");
3085
- const verified = await isVerified(email);
3086
- if (verified) {
3087
- verifyTask.succeed("Email verified!");
3088
- return { email, verified: true };
3604
+ const INVALID_TARGETS2 = [/\.png$/i, /\.jpg$/i, /\.gif$/i, /\.webp$/i, /\.svg$/i, /sprite_sheet/i, /downloaded/i];
3605
+ const isValidStep = (s) => {
3606
+ if (!s?.action) return false;
3607
+ if (s.action === "navigate") {
3608
+ if (!s.target) return false;
3609
+ if (!s.target.startsWith("http://") && !s.target.startsWith("https://")) return false;
3610
+ }
3611
+ if (["click", "type", "select", "check", "upload", "drag"].includes(s.action)) {
3612
+ if (!s.target) return false;
3613
+ if (INVALID_TARGETS2.some((pattern) => pattern.test(s.target))) return false;
3614
+ }
3615
+ if (s.action === "upload") {
3616
+ if (!s.target?.includes("file") && !s.target?.includes("input")) return false;
3617
+ }
3618
+ return true;
3619
+ };
3620
+ plan.steps = plan.steps.filter(isValidStep).map(({ duration, ...step }, i) => ({
3621
+ ...step,
3622
+ id: i + 1,
3623
+ // Ensure upload steps have a value placeholder
3624
+ ...step.action === "upload" && !step.value ? { value: "[file]" } : {}
3625
+ }));
3626
+ if (plan.steps.length === 0) {
3627
+ renumberTask.fail("No valid steps");
3628
+ throw new Error("Plan has no valid steps");
3089
3629
  }
3090
- verifyTask.fail("Not verified yet");
3091
- console.log();
3092
- console.log(chalk6.yellow(" Email not verified. Check your inbox and spam folder."));
3093
- console.log(chalk6.dim(" Run qp again after clicking the verification link."));
3094
- console.log();
3095
- console.log(chalk6.cyan(` Or register/upgrade at: ${getUpgradeUrl(email)}`));
3096
- console.log();
3097
- return { email, verified: false };
3630
+ await writeFile6(planPath, JSON.stringify(plan, null, 2));
3631
+ const removed = originalCount - plan.steps.length;
3632
+ renumberTask.succeed(
3633
+ removed > 0 ? `${plan.steps.length} steps (${removed} removed)` : `${plan.steps.length} steps validated`
3634
+ );
3635
+ return plan;
3098
3636
  }
3099
3637
 
3100
3638
  // src/commands/utils/record-helpers.ts
3101
- import chalk7 from "chalk";
3102
- init_lib();
3103
3639
  init_config_loader();
3104
3640
  init_utils();
3105
3641
  var FREE_VIDEO_LIMIT = 5;
@@ -3124,41 +3660,41 @@ function displayRateLimitInfo() {
3124
3660
  const percentage = Math.round(limits.used / limits.limit * 100);
3125
3661
  if (percentage >= 90) {
3126
3662
  console.log();
3127
- console.log(chalk7.yellow(` \u26A0 ${remaining} requests remaining (${limits.tier} tier)`));
3663
+ console.log(chalk6.yellow(` \u26A0 ${remaining} requests remaining (${limits.tier} tier)`));
3128
3664
  if (limits.tier === "free") {
3129
- console.log(chalk7.dim(` Upgrade: ${getUpgradeUrl()}`));
3665
+ console.log(chalk6.dim(` Upgrade: ${getUpgradeUrl()}`));
3130
3666
  }
3131
3667
  } else if (percentage >= 50) {
3132
- console.log(chalk7.dim(` ${remaining}/${limits.limit} requests remaining`));
3668
+ console.log(chalk6.dim(` ${remaining}/${limits.limit} requests remaining`));
3133
3669
  }
3134
3670
  }
3135
3671
  async function displayUsageStatus(userEmail, userTier, _localVideoCount = 0) {
3136
3672
  const tier = userTier === "pro" ? "pro" : "free";
3137
- const tierLabel = tier === "pro" ? chalk7.green("Pro") : "Free";
3138
- console.log(chalk7.cyan(` \u2714 ${tierLabel} plan`));
3139
- console.log(chalk7.dim(` ${userEmail}`));
3673
+ const tierLabel = tier === "pro" ? chalk6.green("Pro") : "Free";
3674
+ console.log(chalk6.cyan(` \u2714 ${tierLabel} plan`));
3675
+ console.log(chalk6.dim(` ${userEmail}`));
3140
3676
  if (userEmail && userEmail !== "anonymous") {
3141
3677
  try {
3142
3678
  const status = await getStatusCached(userEmail);
3143
3679
  for (const line of formatUsageLines(status, tier)) {
3144
- console.log(chalk7.dim(` ${line}`));
3680
+ console.log(chalk6.dim(` ${line}`));
3145
3681
  }
3146
3682
  } catch {
3147
3683
  }
3148
3684
  }
3149
3685
  if (userTier === "free") {
3150
3686
  const upgradeUrl = getUpgradeUrl(userEmail !== "anonymous" ? userEmail : void 0);
3151
- console.log(chalk7.dim(` Upgrade: ${upgradeUrl}`));
3687
+ console.log(chalk6.dim(` Upgrade: ${upgradeUrl}`));
3152
3688
  }
3153
3689
  }
3154
3690
  function displayPlanSummary(plan) {
3155
3691
  console.log();
3156
- console.log(chalk7.dim(` ${plan.title || "Untitled"}`));
3157
- console.log(chalk7.dim(` ${plan.steps?.length || 0} steps`));
3692
+ console.log(chalk6.dim(` ${plan.title || "Untitled"}`));
3693
+ console.log(chalk6.dim(` ${plan.steps?.length || 0} steps`));
3158
3694
  for (const step of plan.steps || []) {
3159
3695
  const text = step.caption || step.action || "";
3160
3696
  const caption = text.length > 50 ? `${text.slice(0, 47)}...` : text;
3161
- console.log(chalk7.dim(` ${step.id}. ${caption}`));
3697
+ console.log(chalk6.dim(` ${step.id}. ${caption}`));
3162
3698
  }
3163
3699
  }
3164
3700
  function getOutputDirFromUrl(url) {
@@ -3205,6 +3741,86 @@ function getOutputDirFromUrl(url) {
3205
3741
  }
3206
3742
  }
3207
3743
 
3744
+ // src/commands/edit-cmd.ts
3745
+ var PLAN_FILE = /^plan-([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
3746
+ function findPlanFile(dir, lang) {
3747
+ if (lang) {
3748
+ const exact = join10(dir, `plan-${lang}.json`);
3749
+ return existsSync7(exact) ? exact : null;
3750
+ }
3751
+ if (!existsSync7(dir)) return null;
3752
+ const plans = readdirSync(dir).filter((f) => PLAN_FILE.test(f));
3753
+ const preferred = plans.find((f) => f === "plan-en.json") ?? plans[0];
3754
+ return preferred ? join10(dir, preferred) : null;
3755
+ }
3756
+
3757
+ // src/commands/record-cmd.ts
3758
+ import { existsSync as existsSync18 } from "fs";
3759
+ import { mkdir as mkdir9 } from "fs/promises";
3760
+ import { isAbsolute as isAbsolute5, join as join21, resolve as resolve9 } from "path";
3761
+ import chalk15 from "chalk";
3762
+ init_lib();
3763
+ init_config_loader();
3764
+
3765
+ // src/commands/phases/auth-phase.ts
3766
+ init_lib();
3767
+ import chalk9 from "chalk";
3768
+ init_config_loader();
3769
+
3770
+ // src/commands/utils/auth-helpers.ts
3771
+ init_lib();
3772
+ import chalk8 from "chalk";
3773
+ async function handleRegistrationGate() {
3774
+ console.log();
3775
+ console.log(chalk8.yellow(" \u{1F389} You've made 5 free videos!"));
3776
+ console.log(chalk8.white(" Sign in to continue creating videos."));
3777
+ console.log();
3778
+ console.log(chalk8.dim(" Benefits of signing in:"));
3779
+ console.log(chalk8.dim(" \u2022 20 more videos per month"));
3780
+ console.log(chalk8.dim(" \u2022 Keep your video history"));
3781
+ console.log(chalk8.dim(" \u2022 Upgrade to Pro anytime"));
3782
+ let email = await browserLogin();
3783
+ if (!email) {
3784
+ console.log();
3785
+ email = await promptInput("Email: ");
3786
+ }
3787
+ if (!email || !email.includes("@")) {
3788
+ console.log(chalk8.red(" Invalid email. Run qp again to try again."));
3789
+ return null;
3790
+ }
3791
+ const registerTask = task("Sending verification email");
3792
+ const result = await registerFreeUser(email);
3793
+ if (!result.success) {
3794
+ registerTask.fail(result.error || "Registration failed");
3795
+ return null;
3796
+ }
3797
+ if (result.alreadyVerified) {
3798
+ registerTask.succeed("Already verified!");
3799
+ return { email, verified: true };
3800
+ }
3801
+ registerTask.succeed("Check your email!");
3802
+ console.log();
3803
+ console.log(chalk8.cyan(" \u{1F4E7} Verification email sent to:"));
3804
+ console.log(chalk8.white(` ${email}`));
3805
+ console.log();
3806
+ console.log(chalk8.dim(" Click the link in the email, then press Enter to continue..."));
3807
+ await promptInput("");
3808
+ const verifyTask = task("Checking verification");
3809
+ const verified = await isVerified(email);
3810
+ if (verified) {
3811
+ verifyTask.succeed("Email verified!");
3812
+ return { email, verified: true };
3813
+ }
3814
+ verifyTask.fail("Not verified yet");
3815
+ console.log();
3816
+ console.log(chalk8.yellow(" Email not verified. Check your inbox and spam folder."));
3817
+ console.log(chalk8.dim(" Run qp again after clicking the verification link."));
3818
+ console.log();
3819
+ console.log(chalk8.cyan(` Or register/upgrade at: ${getUpgradeUrl(email)}`));
3820
+ console.log();
3821
+ return { email, verified: false };
3822
+ }
3823
+
3208
3824
  // src/commands/phases/auth-phase.ts
3209
3825
  async function runAuthPhase(input) {
3210
3826
  let { userEmail, userTier, localVideoCount } = input;
@@ -3228,7 +3844,7 @@ async function runAuthPhase(input) {
3228
3844
  } else {
3229
3845
  verifyTask.fail("Email not verified");
3230
3846
  console.log();
3231
- console.log(chalk8.yellow(" Check your inbox for the verification email."));
3847
+ console.log(chalk9.yellow(" Check your inbox for the verification email."));
3232
3848
  console.log();
3233
3849
  const input2 = await promptInput(`New email or Enter to retry (${userEmail}): `);
3234
3850
  if (input2.toLowerCase() === "exit") {
@@ -3253,8 +3869,8 @@ async function runAuthPhase(input) {
3253
3869
  registerTask.succeed("Check your email!");
3254
3870
  await saveUserData({ email: newEmail, verified: false });
3255
3871
  console.log();
3256
- console.log(chalk8.cyan(` \u{1F4E7} Verification sent to: ${newEmail}`));
3257
- console.log(chalk8.dim(" Click the link in the email, then run qp again."));
3872
+ console.log(chalk9.cyan(` \u{1F4E7} Verification sent to: ${newEmail}`));
3873
+ console.log(chalk9.dim(" Click the link in the email, then run qp again."));
3258
3874
  console.log();
3259
3875
  gracefulExit();
3260
3876
  return null;
@@ -3270,7 +3886,7 @@ async function runAuthPhase(input) {
3270
3886
  } else {
3271
3887
  recheckTask.fail("Still not verified");
3272
3888
  console.log();
3273
- console.log(chalk8.cyan(` Register/upgrade at: ${getUpgradeUrl(userEmail)}`));
3889
+ console.log(chalk9.cyan(` Register/upgrade at: ${getUpgradeUrl(userEmail)}`));
3274
3890
  console.log();
3275
3891
  gracefulExit();
3276
3892
  return null;
@@ -3297,8 +3913,8 @@ async function runAuthPhase(input) {
3297
3913
  } else {
3298
3914
  const remaining = FREE_VIDEO_LIMIT - localVideoCount;
3299
3915
  console.log();
3300
- console.log(chalk8.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
3301
- console.log(chalk8.dim(` ${remaining - 1} more after this \u2022 Sign up for unlimited`));
3916
+ console.log(chalk9.cyan(` \u{1F3AC} Video ${localVideoCount + 1}/${FREE_VIDEO_LIMIT} free`));
3917
+ console.log(chalk9.dim(` ${remaining - 1} more after this \u2022 Sign up for unlimited`));
3302
3918
  userEmail = await setupUserEmail();
3303
3919
  let registeredVerified = false;
3304
3920
  try {
@@ -3317,18 +3933,18 @@ async function runAuthPhase(input) {
3317
3933
  }
3318
3934
 
3319
3935
  // src/commands/phases/plan-phase.ts
3320
- import { existsSync as existsSync7, readdirSync } from "fs";
3936
+ import { existsSync as existsSync8, readdirSync as readdirSync2 } from "fs";
3321
3937
  import { readFile as readFile4 } from "fs/promises";
3322
- import { basename, join as join10 } from "path";
3323
- import chalk9 from "chalk";
3938
+ import { basename, join as join11 } from "path";
3939
+ import chalk10 from "chalk";
3324
3940
 
3325
3941
  // src/ai/fix.ts
3326
3942
  init_lib();
3327
3943
  async function callGroqAndFinalize(systemPrompt, userMessage, plan, logError2, userEmail, tier = "free") {
3328
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
3329
- const jsonMatch = content.match(/\{[\s\S]*\}/);
3330
- if (!jsonMatch) throw new Error("No JSON in AI response");
3331
- return finalizePlan(JSON.parse(jsonMatch[0]), plan);
3944
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "fix");
3945
+ const result = await parseAIJsonWithRepair(content, { logError: logError2, userEmail, tier, context: "Plan Fix" });
3946
+ if (!Array.isArray(result.steps)) throw new Error("AI response has no steps array");
3947
+ return finalizePlan(result, plan);
3332
3948
  }
3333
3949
  function finalizePlan(result, original) {
3334
3950
  result.url = original.url;
@@ -3452,10 +4068,15 @@ async function translatePlan(plan, targetLang, logError2, userEmail, tier = "fre
3452
4068
  ${JSON.stringify(toTranslate)}
3453
4069
 
3454
4070
  Return ONLY JSON: {"description": "...", "captions": ["...", ...]}`;
3455
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
3456
- const jsonMatch = content.match(/\{[\s\S]*\}/);
3457
- if (!jsonMatch) throw new Error("No JSON in translation response");
3458
- const translated = JSON.parse(jsonMatch[0]);
4071
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "fix");
4072
+ const translated = await parseAIJsonWithRepair(content, {
4073
+ logError: logError2,
4074
+ userEmail,
4075
+ tier,
4076
+ shape: "The JSON should have: description (string), captions (array of strings).",
4077
+ context: "Translate Plan"
4078
+ });
4079
+ if (!Array.isArray(translated.captions)) throw new Error("Translation response has no captions array");
3459
4080
  const translatedPlan = JSON.parse(JSON.stringify(plan));
3460
4081
  translatedPlan.title = plan.title;
3461
4082
  translatedPlan.description = translated.description || plan.description;
@@ -3473,12 +4094,22 @@ Return ONLY JSON: {"description": "...", "captions": ["...", ...]}`;
3473
4094
  init_config_loader();
3474
4095
  var INDENT3 = " ";
3475
4096
  async function runPlanPhase(params) {
3476
- const { url, config, userEmail, description, planPath, legacyPlanPath, targetLang, userTier = "free" } = params;
4097
+ const {
4098
+ url,
4099
+ config,
4100
+ userEmail,
4101
+ description,
4102
+ planPath,
4103
+ legacyPlanPath,
4104
+ targetLang,
4105
+ userTier = "free",
4106
+ fresh = false
4107
+ } = params;
3477
4108
  const maxSteps = config.plan?.maxSteps ?? 8;
3478
4109
  const extensionPath = config.browser?.extension;
3479
4110
  let existingPlan = null;
3480
4111
  let existingPlanPath = null;
3481
- if (existsSync7(planPath)) {
4112
+ if (existsSync8(planPath)) {
3482
4113
  try {
3483
4114
  const parsed = JSON.parse(await readFile4(planPath, "utf-8"));
3484
4115
  if (parsed.url && parsed.steps?.length > 0) {
@@ -3490,10 +4121,10 @@ async function runPlanPhase(params) {
3490
4121
  }
3491
4122
  if (!existingPlan) {
3492
4123
  try {
3493
- const files = readdirSync(config.output.dir);
4124
+ const files = readdirSync2(config.output.dir);
3494
4125
  const planFiles = files.filter((f) => /^plan-[a-z]{2}(-[A-Z]{2})?\.json$/.test(f) && f !== basename(planPath));
3495
4126
  for (const file of planFiles) {
3496
- const filePath = join10(config.output.dir, file);
4127
+ const filePath = join11(config.output.dir, file);
3497
4128
  try {
3498
4129
  const parsed = JSON.parse(await readFile4(filePath, "utf-8"));
3499
4130
  if (parsed.url && parsed.steps?.length > 0) {
@@ -3507,7 +4138,7 @@ async function runPlanPhase(params) {
3507
4138
  } catch {
3508
4139
  }
3509
4140
  }
3510
- if (!existingPlan && existsSync7(legacyPlanPath)) {
4141
+ if (!existingPlan && existsSync8(legacyPlanPath)) {
3511
4142
  try {
3512
4143
  const parsed = JSON.parse(await readFile4(legacyPlanPath, "utf-8"));
3513
4144
  if (parsed.url && parsed.steps?.length > 0) {
@@ -3519,6 +4150,11 @@ async function runPlanPhase(params) {
3519
4150
  }
3520
4151
  const normalizeUrl2 = (u) => u.replace(/\/+$/, "").toLowerCase();
3521
4152
  const urlMatches = existingPlan && normalizeUrl2(existingPlan.url) === normalizeUrl2(url);
4153
+ if (existingPlan && !urlMatches && isAutoYes()) {
4154
+ console.log();
4155
+ console.log(chalk10.dim(` Existing plan is for ${existingPlan.url}, not ${url} \u2014 regenerating.`));
4156
+ existingPlan = null;
4157
+ }
3522
4158
  let plan;
3523
4159
  const doTranslate = async (failMsg) => {
3524
4160
  const translateTask = task(`Translating to ${targetLang}`);
@@ -3533,25 +4169,25 @@ async function runPlanPhase(params) {
3533
4169
  translateTask.fail(failMsg);
3534
4170
  }
3535
4171
  };
3536
- if (existingPlan && existingPlanPath) {
4172
+ if (existingPlan && existingPlanPath && !fresh) {
3537
4173
  console.log();
3538
- console.log(chalk9.dim(` Found existing plan: `) + chalk9.cyan(formatClickablePath(existingPlanPath)));
3539
- console.log(chalk9.dim(` ${existingPlan.steps.length} steps for ${existingPlan.url}`));
4174
+ console.log(chalk10.dim(` Found existing plan: `) + chalk10.cyan(formatClickablePath(existingPlanPath)));
4175
+ console.log(chalk10.dim(` ${existingPlan.steps.length} steps for ${existingPlan.url}`));
3540
4176
  if (!urlMatches) {
3541
- console.log(chalk9.yellow(` \u26A0 Plan URL differs from argument:`));
3542
- console.log(chalk9.yellow(` Plan: ${existingPlan.url}`));
3543
- console.log(chalk9.yellow(` Arg: ${url}`));
4177
+ console.log(chalk10.yellow(` \u26A0 Plan URL differs from argument:`));
4178
+ console.log(chalk10.yellow(` Plan: ${existingPlan.url}`));
4179
+ console.log(chalk10.yellow(` Arg: ${url}`));
3544
4180
  }
3545
4181
  const existingLang = existingPlan.lang || "en";
3546
4182
  if (existingLang !== targetLang) {
3547
- console.log(chalk9.yellow(` \u26A0 Plan is in ${existingLang}, config is ${targetLang}`));
4183
+ console.log(chalk10.yellow(` \u26A0 Plan is in ${existingLang}, config is ${targetLang}`));
3548
4184
  }
3549
4185
  const reuse = await confirm(`${INDENT3}Use existing plan?`);
3550
4186
  if (reuse) {
3551
4187
  plan = await validateAndFixPlan(existingPlanPath, task, logError, userEmail, userTier);
3552
4188
  if (existingPlanPath !== planPath) {
3553
4189
  await savePlan(plan, planPath);
3554
- console.log(chalk9.dim(` Migrated to: ${formatClickablePath(planPath)}`));
4190
+ console.log(chalk10.dim(` Migrated to: ${formatClickablePath(planPath)}`));
3555
4191
  }
3556
4192
  if (existingLang !== targetLang) {
3557
4193
  const translate = await confirm(`${INDENT3}Translate to ${targetLang}?`);
@@ -3569,7 +4205,8 @@ async function runPlanPhase(params) {
3569
4205
  userEmail,
3570
4206
  extensionPath,
3571
4207
  config.output.dir,
3572
- userTier
4208
+ userTier,
4209
+ { width: config.video.width, height: config.video.height }
3573
4210
  );
3574
4211
  displayRateLimitInfo();
3575
4212
  if (urlMatches) {
@@ -3586,7 +4223,7 @@ async function runPlanPhase(params) {
3586
4223
  uploadIdx++;
3587
4224
  }
3588
4225
  }
3589
- console.log(chalk9.dim(` Preserved upload paths from previous plan`));
4226
+ console.log(chalk10.dim(` Preserved upload paths from previous plan`));
3590
4227
  }
3591
4228
  await savePlan(plan, planPath);
3592
4229
  plan = await validateAndFixPlan(planPath, task, logError, userEmail, userTier);
@@ -3602,7 +4239,9 @@ async function runPlanPhase(params) {
3602
4239
  logError,
3603
4240
  userEmail,
3604
4241
  extensionPath,
3605
- config.output.dir
4242
+ config.output.dir,
4243
+ void 0,
4244
+ { width: config.video.width, height: config.video.height }
3606
4245
  );
3607
4246
  displayRateLimitInfo();
3608
4247
  await savePlan(plan, planPath);
@@ -3610,8 +4249,8 @@ async function runPlanPhase(params) {
3610
4249
  }
3611
4250
  displayPlanSummary(plan);
3612
4251
  console.log();
3613
- console.log(chalk9.dim(` Plan: ${formatClickablePath(planPath)}`));
3614
- console.log(chalk9.dim(` Enter a prompt to refine, or press Enter to continue.`));
4252
+ console.log(chalk10.dim(` Plan: ${formatClickablePath(planPath)}`));
4253
+ console.log(chalk10.dim(` Enter a prompt to refine, or press Enter to continue.`));
3615
4254
  const MAX_REFINEMENTS = 10;
3616
4255
  let refinementCount = 0;
3617
4256
  while (true) {
@@ -3629,15 +4268,15 @@ async function runPlanPhase(params) {
3629
4268
  refineTask.succeed("Plan refined");
3630
4269
  displayRateLimitInfo();
3631
4270
  if (plan.steps.length > maxSteps) {
3632
- console.log(chalk9.yellow(` \u26A0 ${plan.steps.length} steps exceeds limit of ${maxSteps}`));
4271
+ console.log(chalk10.yellow(` \u26A0 ${plan.steps.length} steps exceeds limit of ${maxSteps}`));
3633
4272
  }
3634
4273
  displayPlanSummary(plan);
3635
4274
  console.log();
3636
- console.log(chalk9.dim(` Plan: ${formatClickablePath(planPath)}`));
4275
+ console.log(chalk10.dim(` Plan: ${formatClickablePath(planPath)}`));
3637
4276
  if (refinementCount >= MAX_REFINEMENTS) {
3638
4277
  console.log();
3639
- console.log(chalk9.yellow(` \u26A0 You've refined the plan ${MAX_REFINEMENTS} times.`));
3640
- console.log(chalk9.dim(` Further refinements may incur additional costs.`));
4278
+ console.log(chalk10.yellow(` \u26A0 You've refined the plan ${MAX_REFINEMENTS} times.`));
4279
+ console.log(chalk10.dim(` Further refinements may incur additional costs.`));
3641
4280
  const continueRefining = await confirm(`${INDENT3}Continue refining?`);
3642
4281
  if (!continueRefining) break;
3643
4282
  }
@@ -3649,12 +4288,12 @@ async function runPlanPhase(params) {
3649
4288
  const planLang = plan.lang || "en";
3650
4289
  if (planLang !== targetLang) {
3651
4290
  let useExistingTranslation = false;
3652
- if (existsSync7(planPath) && existingPlanPath !== planPath) {
4291
+ if (existsSync8(planPath) && existingPlanPath !== planPath) {
3653
4292
  try {
3654
4293
  const translatedPlan = JSON.parse(await readFile4(planPath, "utf-8"));
3655
4294
  if (translatedPlan.steps?.length === plan.steps.length && translatedPlan.lang === targetLang) {
3656
4295
  console.log();
3657
- console.log(chalk9.dim(` Found translated plan: ${formatClickablePath(planPath)}`));
4296
+ console.log(chalk10.dim(` Found translated plan: ${formatClickablePath(planPath)}`));
3658
4297
  const useTranslated = await confirm(`${INDENT3}Use ${targetLang} translation?`);
3659
4298
  if (useTranslated) {
3660
4299
  plan = translatedPlan;
@@ -3666,7 +4305,7 @@ async function runPlanPhase(params) {
3666
4305
  }
3667
4306
  if (!useExistingTranslation) {
3668
4307
  console.log();
3669
- console.log(chalk9.yellow(` Plan is in ${planLang}, but voice is set to ${targetLang}`));
4308
+ console.log(chalk10.yellow(` Plan is in ${planLang}, but voice is set to ${targetLang}`));
3670
4309
  const translate = await confirm(`${INDENT3}Translate captions to ${targetLang}?`);
3671
4310
  if (translate) await doTranslate("Translation failed, using original");
3672
4311
  }
@@ -3676,32 +4315,32 @@ async function runPlanPhase(params) {
3676
4315
 
3677
4316
  // src/commands/phases/validation-phase.ts
3678
4317
  import { readFile as readFile7 } from "fs/promises";
3679
- import chalk11 from "chalk";
4318
+ import chalk12 from "chalk";
3680
4319
 
3681
4320
  // src/record.ts
3682
- import { existsSync as existsSync12 } from "fs";
3683
- import { mkdir as mkdir6 } from "fs/promises";
3684
- import { join as join15 } from "path";
3685
- import chalk10 from "chalk";
4321
+ import { existsSync as existsSync13 } from "fs";
4322
+ import { mkdir as mkdir7 } from "fs/promises";
4323
+ import { join as join16 } from "path";
4324
+ import chalk11 from "chalk";
3686
4325
 
3687
4326
  // src/browser/actions.ts
3688
- import { existsSync as existsSync9 } from "fs";
4327
+ import { existsSync as existsSync10 } from "fs";
3689
4328
  import { readFile as readFile5 } from "fs/promises";
3690
4329
  import { isAbsolute as isAbsolute2, resolve as resolve5 } from "path";
3691
4330
 
3692
4331
  // src/browser/effects.ts
3693
4332
  init_lib();
3694
- import { existsSync as existsSync8, readFileSync as readFileSync3 } from "fs";
3695
- import { join as join11 } from "path";
4333
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
4334
+ import { join as join12 } from "path";
3696
4335
  function loadHighlightCSS() {
3697
4336
  const candidates = [
3698
- join11(__dirname, "highlight.css"),
4337
+ join12(__dirname, "highlight.css"),
3699
4338
  // dist/ build
3700
- join11(__dirname, "..", "highlight.css")
4339
+ join12(__dirname, "..", "highlight.css")
3701
4340
  // src/browser/ → src/
3702
4341
  ];
3703
4342
  for (const p of candidates) {
3704
- if (existsSync8(p)) return readFileSync3(p, "utf-8");
4343
+ if (existsSync9(p)) return readFileSync5(p, "utf-8");
3705
4344
  }
3706
4345
  return "";
3707
4346
  }
@@ -3901,6 +4540,10 @@ async function resolveElement(page, target) {
3901
4540
  await locator.waitFor({ state: "visible", timeout });
3902
4541
  return await locator.elementHandle({ timeout: 1e3 });
3903
4542
  }
4543
+ if (target.startsWith("#") && !/^#[A-Za-z_-][\w-]*$/.test(target)) {
4544
+ const attr = `[id="${target.slice(1).replace(/(["\\])/g, "\\$1")}"]`;
4545
+ return await page.waitForSelector(attr, { state: "visible", timeout });
4546
+ }
3904
4547
  return await page.waitForSelector(target, { state: "visible", timeout });
3905
4548
  } catch {
3906
4549
  return null;
@@ -4025,7 +4668,14 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir) {
4025
4668
  }
4026
4669
  } else {
4027
4670
  const amount = direction === "up" ? -PAGE_SCROLL_PX : PAGE_SCROLL_PX;
4028
- await page.evaluate((y) => window.scrollBy(0, y), amount);
4671
+ const moved = await page.evaluate((y) => {
4672
+ const before = window.scrollY;
4673
+ window.scrollBy(0, y);
4674
+ return window.scrollY - before;
4675
+ }, amount);
4676
+ if (moved === 0) {
4677
+ throw new Error("scroll moved nothing: the page has no scroll headroom at this viewport");
4678
+ }
4029
4679
  }
4030
4680
  break;
4031
4681
  }
@@ -4043,7 +4693,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir) {
4043
4693
  if (!isPlaceholder) {
4044
4694
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
4045
4695
  const filePath = isAbsolute2(normalizedPath) ? normalizedPath : resolve5(baseDir, normalizedPath);
4046
- if (existsSync9(filePath)) {
4696
+ if (existsSync10(filePath)) {
4047
4697
  await fileInput.setInputFiles(filePath);
4048
4698
  await page.waitForTimeout(2e3);
4049
4699
  break;
@@ -4160,7 +4810,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir) {
4160
4810
  break;
4161
4811
  case "image": {
4162
4812
  const imagePath = step.value ? isAbsolute2(step.value) ? step.value : resolve5(baseDir, step.value) : null;
4163
- if (imagePath && existsSync9(imagePath)) {
4813
+ if (imagePath && existsSync10(imagePath)) {
4164
4814
  const imgStyle = step.target === "stretch" ? `width: ${config.video.width}px; height: ${config.video.height}px; object-fit: cover;` : "max-width: 90%; max-height: 90%;";
4165
4815
  if (await showImagePage(page, imagePath, imgStyle, config, cursorInjected)) injected = true;
4166
4816
  }
@@ -4190,7 +4840,7 @@ async function executeStep(page, step, plan, config, cursorInjected, baseDir) {
4190
4840
  await pendingDownload;
4191
4841
  pendingDownload = null;
4192
4842
  }
4193
- if (lastDownloadPath && existsSync9(lastDownloadPath)) {
4843
+ if (lastDownloadPath && existsSync10(lastDownloadPath)) {
4194
4844
  const imgStyle = step.target === "stretch" ? "width: 100%; height: 100%; object-fit: contain;" : "max-width: 90%; max-height: 90%; object-fit: contain;";
4195
4845
  if (await showImagePage(page, lastDownloadPath, imgStyle, config, cursorInjected)) injected = true;
4196
4846
  }
@@ -4235,22 +4885,22 @@ init_lib();
4235
4885
 
4236
4886
  // src/browser/dry-run.ts
4237
4887
  init_utils();
4238
- import { join as join13 } from "path";
4888
+ import { join as join14 } from "path";
4239
4889
 
4240
4890
  // src/browser/step-validator.ts
4241
4891
  init_utils();
4242
- import { existsSync as existsSync10, readdirSync as readdirSync2 } from "fs";
4243
- import { basename as basename2, isAbsolute as isAbsolute3, join as join12, resolve as resolve6 } from "path";
4892
+ import { existsSync as existsSync11, readdirSync as readdirSync3 } from "fs";
4893
+ import { basename as basename2, isAbsolute as isAbsolute3, join as join13, resolve as resolve6 } from "path";
4244
4894
  function findMediaFile(filename, baseDir) {
4245
4895
  const name = basename2(filename);
4246
- const searchDirs = [baseDir, join12(baseDir, "res"), join12(baseDir, "assets"), join12(baseDir, "media")];
4896
+ const searchDirs = [baseDir, join13(baseDir, "res"), join13(baseDir, "assets"), join13(baseDir, "media")];
4247
4897
  for (const dir of searchDirs) {
4248
- if (!existsSync10(dir)) continue;
4898
+ if (!existsSync11(dir)) continue;
4249
4899
  try {
4250
- const files = readdirSync2(dir);
4251
- if (files.includes(name)) return join12(dir, name);
4900
+ const files = readdirSync3(dir);
4901
+ if (files.includes(name)) return join13(dir, name);
4252
4902
  const match = files.find((f) => f.toLowerCase() === name.toLowerCase());
4253
- if (match) return join12(dir, match);
4903
+ if (match) return join13(dir, match);
4254
4904
  } catch {
4255
4905
  }
4256
4906
  }
@@ -4323,7 +4973,7 @@ function resolveMediaStep(step, baseDir, label, startTime) {
4323
4973
  return { success: false, error: `Missing ${label} path`, executionTimeMs: Date.now() - startTime };
4324
4974
  }
4325
4975
  const specifiedPath = isAbsolute3(step.value) ? step.value : resolve6(baseDir, step.value);
4326
- if (existsSync10(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
4976
+ if (existsSync11(specifiedPath)) return { success: true, executionTimeMs: Date.now() - startTime };
4327
4977
  const foundPath = findMediaFile(step.value, baseDir);
4328
4978
  if (foundPath) {
4329
4979
  step.value = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -4445,7 +5095,7 @@ async function validateAndExecuteStep(page, step, plan, baseDir) {
4445
5095
  if (!isPlaceholder) {
4446
5096
  const normalizedPath = rawPath.startsWith("mnt/") ? `/${rawPath}` : rawPath;
4447
5097
  let filePath = isAbsolute3(normalizedPath) ? normalizedPath : resolve6(baseDir, normalizedPath);
4448
- if (!existsSync10(filePath)) {
5098
+ if (!existsSync11(filePath)) {
4449
5099
  const foundPath = findMediaFile(rawPath, baseDir);
4450
5100
  if (foundPath) {
4451
5101
  const relativePath = `./${foundPath.replace(`${baseDir}/`, "").replace(`${baseDir}\\`, "")}`;
@@ -4544,7 +5194,7 @@ async function getPageElements(page) {
4544
5194
  });
4545
5195
  }
4546
5196
  async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart, onStepEnd) {
4547
- const userDataDir = join13(baseDir, ".browser-data");
5197
+ const userDataDir = join14(baseDir, ".browser-data");
4548
5198
  const { context, browser, chromeSession } = await launchBrowserContext(
4549
5199
  config,
4550
5200
  userDataDir,
@@ -4609,17 +5259,17 @@ async function validateStepsOnPage(plan, config, logError2, baseDir, onStepStart
4609
5259
  }
4610
5260
 
4611
5261
  // src/browser/meta.ts
4612
- import { existsSync as existsSync11 } from "fs";
5262
+ import { existsSync as existsSync12 } from "fs";
4613
5263
  import { readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
4614
- import { join as join14 } from "path";
5264
+ import { join as join15 } from "path";
4615
5265
  var RECORDING_FILE = "recording.mp4";
4616
5266
  var RECORDING_META_FILE = "recording.meta.json";
4617
5267
  function getRecordingPath(outputDir) {
4618
- return join14(outputDir, RECORDING_FILE);
5268
+ return join15(outputDir, RECORDING_FILE);
4619
5269
  }
4620
5270
  async function loadRecordingMeta(outputDir) {
4621
- const metaPath = join14(outputDir, RECORDING_META_FILE);
4622
- if (!existsSync11(metaPath)) return null;
5271
+ const metaPath = join15(outputDir, RECORDING_META_FILE);
5272
+ if (!existsSync12(metaPath)) return null;
4623
5273
  try {
4624
5274
  const content = await readFile6(metaPath, "utf-8");
4625
5275
  return JSON.parse(content);
@@ -4628,7 +5278,7 @@ async function loadRecordingMeta(outputDir) {
4628
5278
  }
4629
5279
  }
4630
5280
  async function saveRecordingMeta(outputDir, meta) {
4631
- const metaPath = join14(outputDir, RECORDING_META_FILE);
5281
+ const metaPath = join15(outputDir, RECORDING_META_FILE);
4632
5282
  await writeFile7(metaPath, JSON.stringify(meta, null, 2));
4633
5283
  }
4634
5284
  function canReuseRecording(plan, config, stepDurations, existingMeta) {
@@ -4698,15 +5348,15 @@ function formatStepLabel(step) {
4698
5348
  return `${caption.slice(0, 47)}...`;
4699
5349
  }
4700
5350
  async function recordSession(plan, config, stepDurations, workDir, baseDir, task2, logError2) {
4701
- const videoDir = join15(workDir, "video");
4702
- await mkdir6(videoDir, { recursive: true });
5351
+ const videoDir = join16(workDir, "video");
5352
+ await mkdir7(videoDir, { recursive: true });
4703
5353
  const getStepDuration = (stepNum) => {
4704
5354
  return stepDurations[stepNum - 1] ?? config.timing.minStepDuration;
4705
5355
  };
4706
5356
  const browserTask = task2("Starting browser recording");
4707
5357
  const recordingStartTime = Date.now();
4708
- const userDataDir = join15(workDir, "browser-data");
4709
- const recordVideo = { dir: videoDir, size: { width: config.video.width, height: config.video.height } };
5358
+ const userDataDir = join16(workDir, "browser-data");
5359
+ const recordVideo = { dir: videoDir, size: recordingViewport(config) };
4710
5360
  const authState = findAuthState(plan.url);
4711
5361
  const { context, browser, chromeSession } = await launchBrowserContext(config, userDataDir, recordVideo, authState);
4712
5362
  const page = context.pages()[0] || await context.newPage();
@@ -4727,6 +5377,7 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4727
5377
  let onMediaPage = firstStepIsMedia;
4728
5378
  const failures = [];
4729
5379
  const actualStepDurations = [];
5380
+ let pageClosedEarly = false;
4730
5381
  for (let i = 0; i < plan.steps.length; i++) {
4731
5382
  const step = plan.steps[i];
4732
5383
  const stepNum = i + 1;
@@ -4753,6 +5404,12 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4753
5404
  let targetEl = null;
4754
5405
  if (step.target) {
4755
5406
  targetEl = await resolveElement(page, step.target);
5407
+ if (!targetEl) {
5408
+ console.log(
5409
+ `
5410
+ \u26A0 target "${step.target}" not found on page \u2014 step ${stepNum} recorded without the action`
5411
+ );
5412
+ }
4756
5413
  }
4757
5414
  if (step.zoom && step.zoom !== 1 && targetEl && !isExtensionPage) {
4758
5415
  const preZoomBox = await targetEl.boundingBox() ?? void 0;
@@ -4789,11 +5446,19 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4789
5446
  }
4790
5447
  const stepElapsed = Date.now() - stepStartTime;
4791
5448
  const waitTime = Math.max(0, stepDuration - stepElapsed);
4792
- if (waitTime > 0) await page.waitForTimeout(waitTime);
4793
5449
  const stepDelay = config.video.stepDelay ?? 500;
4794
5450
  const isLastStep = i === plan.steps.length - 1;
4795
- if (!isLastStep && stepDelay > 0) {
4796
- await page.waitForTimeout(stepDelay);
5451
+ try {
5452
+ if (waitTime > 0) await page.waitForTimeout(waitTime);
5453
+ if (!isLastStep && stepDelay > 0) await page.waitForTimeout(stepDelay);
5454
+ } catch (err) {
5455
+ if (page.isClosed()) {
5456
+ console.log(chalk11.yellow("\n \u26A0 Browser page closed early \u2014 saving the recording captured so far"));
5457
+ pageClosedEarly = true;
5458
+ actualStepDurations.push(Math.max(stepElapsed, stepDuration));
5459
+ break;
5460
+ }
5461
+ throw err;
4797
5462
  }
4798
5463
  if (!isExtensionPage) {
4799
5464
  const shouldHighlight = step.highlight !== void 0 ? step.highlight : config.video.highlight;
@@ -4806,18 +5471,19 @@ async function recordSession(plan, config, stepDurations, workDir, baseDir, task
4806
5471
  actualStepDurations.push(actualDuration);
4807
5472
  }
4808
5473
  if (failures.length > 0) {
4809
- console.log(chalk10.yellow(` \u26A0 ${failures.length} step(s) skipped:`));
5474
+ console.log(chalk11.yellow(` \u26A0 ${failures.length} step(s) skipped:`));
4810
5475
  for (const f of failures) {
4811
- console.log(chalk10.yellow(` - ${f}`));
5476
+ console.log(chalk11.yellow(` - ${f}`));
4812
5477
  }
4813
5478
  }
5479
+ while (actualStepDurations.length < plan.steps.length) actualStepDurations.push(0);
4814
5480
  const saveTask = task2("Saving video");
4815
5481
  const video = page.video();
4816
5482
  if (!video) throw new Error("No video recorder available");
4817
- await page.close();
5483
+ if (!pageClosedEarly) await page.close();
4818
5484
  const videoPath = await video.path();
4819
5485
  await cleanupBrowserSession({ context, browser, chromeSession });
4820
- if (!videoPath || !existsSync12(videoPath)) {
5486
+ if (!videoPath || !existsSync13(videoPath)) {
4821
5487
  throw new Error("Video file was not created");
4822
5488
  }
4823
5489
  saveTask.succeed("Video saved");
@@ -4876,21 +5542,21 @@ async function runValidationPhase(params) {
4876
5542
  const stepNum = result.stepIndex + 1;
4877
5543
  const step = plan.steps[result.stepIndex];
4878
5544
  console.log();
4879
- console.log(chalk11.red(` \u2717 Step ${stepNum}: ${step.action}`));
5545
+ console.log(chalk12.red(` \u2717 Step ${stepNum}: ${step.action}`));
4880
5546
  const caption = step.caption || "";
4881
- if (caption) console.log(chalk11.dim(` Caption: ${caption.slice(0, 50)}${caption.length > 50 ? "..." : ""}`));
4882
- console.log(chalk11.dim(` Target: ${step.target || "none"}`));
4883
- if (step.value) console.log(chalk11.dim(` Value: ${step.value}`));
4884
- console.log(chalk11.dim(` Error: ${result.error}`));
5547
+ if (caption) console.log(chalk12.dim(` Caption: ${caption.slice(0, 50)}${caption.length > 50 ? "..." : ""}`));
5548
+ console.log(chalk12.dim(` Target: ${step.target || "none"}`));
5549
+ if (step.value) console.log(chalk12.dim(` Value: ${step.value}`));
5550
+ console.log(chalk12.dim(` Error: ${result.error}`));
4885
5551
  if (result.suggestion) {
4886
- console.log(chalk11.yellow(` Hint: ${result.suggestion}`));
5552
+ console.log(chalk12.yellow(` Hint: ${result.suggestion}`));
4887
5553
  }
4888
5554
  console.log();
4889
5555
  const isUploadMissingFile = step.action === "upload" && (!step.value || step.value.startsWith("[") || step.value.includes("example") || step.value.includes("/path/to"));
4890
5556
  if (isUploadMissingFile) {
4891
5557
  const filePath = await promptInput('Enter file path to upload (or "skip"): ');
4892
5558
  if (filePath.toLowerCase() === "skip") {
4893
- console.log(chalk11.yellow(` Skipping step ${stepNum}`));
5559
+ console.log(chalk12.yellow(` Skipping step ${stepNum}`));
4894
5560
  continue;
4895
5561
  }
4896
5562
  if (filePath) {
@@ -4899,13 +5565,13 @@ async function runValidationPhase(params) {
4899
5565
  const revalidation2 = await validateStepsOnPage(plan, config, logError, config.output.dir);
4900
5566
  const newResult2 = revalidation2.results[result.stepIndex];
4901
5567
  if (newResult2?.success) {
4902
- console.log(chalk11.green(` \u2713 Step ${stepNum} now passes`));
5568
+ console.log(chalk12.green(` \u2713 Step ${stepNum} now passes`));
4903
5569
  validationResult = revalidation2;
4904
5570
  continue;
4905
5571
  }
4906
5572
  result.error = newResult2?.error;
4907
5573
  result.suggestion = newResult2?.suggestion;
4908
- console.log(chalk11.red(` Still failing: ${result.error}`));
5574
+ console.log(chalk12.red(` Still failing: ${result.error}`));
4909
5575
  }
4910
5576
  }
4911
5577
  const fixTask = task(`Fixing step ${stepNum} with AI`);
@@ -4927,8 +5593,8 @@ async function runValidationPhase(params) {
4927
5593
  await savePlan(plan, planPath);
4928
5594
  fixTask.succeed("AI suggested a fix");
4929
5595
  const fixedStep = plan.steps[result.stepIndex];
4930
- console.log(chalk11.dim(` New target: ${fixedStep.target || "none"}`));
4931
- if (fixedStep.value) console.log(chalk11.dim(` New value: ${fixedStep.value}`));
5596
+ console.log(chalk12.dim(` New target: ${fixedStep.target || "none"}`));
5597
+ if (fixedStep.value) console.log(chalk12.dim(` New value: ${fixedStep.value}`));
4932
5598
  } catch {
4933
5599
  fixTask.fail("AI could not fix automatically");
4934
5600
  }
@@ -4944,11 +5610,11 @@ async function runValidationPhase(params) {
4944
5610
  result.error = newResult?.error;
4945
5611
  result.suggestion = newResult?.suggestion;
4946
5612
  console.log();
4947
- console.log(chalk11.yellow(` Edit step ${stepNum} in: `) + chalk11.cyan(formatClickablePath(planPath)));
5613
+ console.log(chalk12.yellow(` Edit step ${stepNum} in: `) + chalk12.cyan(formatClickablePath(planPath)));
4948
5614
  console.log();
4949
5615
  const choice = await promptInput('Press Enter after editing, or type "skip" to skip this step: ');
4950
5616
  if (choice.toLowerCase() === "skip") {
4951
- console.log(chalk11.yellow(` Skipping step ${stepNum} - may fail during recording`));
5617
+ console.log(chalk12.yellow(` Skipping step ${stepNum} - may fail during recording`));
4952
5618
  continue;
4953
5619
  }
4954
5620
  plan = JSON.parse(await readFile7(planPath, "utf-8"));
@@ -4976,17 +5642,17 @@ async function runValidationPhase(params) {
4976
5642
 
4977
5643
  // src/commands/phases/video-phase.ts
4978
5644
  init_lib();
4979
- import { existsSync as existsSync14 } from "fs";
5645
+ import { existsSync as existsSync15 } from "fs";
4980
5646
  import { copyFile } from "fs/promises";
4981
- import { join as join17, resolve as resolve8 } from "path";
5647
+ import { join as join18, resolve as resolve8 } from "path";
4982
5648
 
4983
5649
  // src/utils/video-pipeline.ts
4984
5650
  init_lib();
4985
5651
  import { chunkWordsByCount, assTimeFromMs as formatASSTime } from "@spark-apps/video-kit";
4986
- import { existsSync as existsSync13 } from "fs";
4987
- import { mkdir as mkdir7, writeFile as writeFile8 } from "fs/promises";
4988
- import { isAbsolute as isAbsolute4, join as join16, resolve as resolve7 } from "path";
4989
- import chalk12 from "chalk";
5652
+ import { existsSync as existsSync14 } from "fs";
5653
+ import { mkdir as mkdir8, writeFile as writeFile8 } from "fs/promises";
5654
+ import { isAbsolute as isAbsolute4, join as join17, resolve as resolve7 } from "path";
5655
+ import chalk13 from "chalk";
4990
5656
  var INDENT4 = " ";
4991
5657
  async function generateASS(plan, stepAudio, workDir, config, captions) {
4992
5658
  const regularColor = hexToAss(captions.regular.color);
@@ -5033,7 +5699,7 @@ async function generateASS(plan, stepAudio, workDir, config, captions) {
5033
5699
  }
5034
5700
  currentTime += audio.duration;
5035
5701
  }
5036
- const assPath = join16(workDir, "subtitles.ass");
5702
+ const assPath = join17(workDir, "subtitles.ass");
5037
5703
  await writeFile8(assPath, ass);
5038
5704
  return assPath;
5039
5705
  }
@@ -5048,7 +5714,7 @@ function buildVideoOverlays(steps, stepDurations, baseDir) {
5048
5714
  const ext = step.value.split(".").pop()?.toLowerCase() || "";
5049
5715
  if (["mp4", "webm", "mov"].includes(ext)) {
5050
5716
  const mediaPath = isAbsolute4(step.value) ? step.value : resolve7(baseDir, step.value);
5051
- if (existsSync13(mediaPath)) {
5717
+ if (existsSync14(mediaPath)) {
5052
5718
  const partial = parsePartial(step.partial);
5053
5719
  overlays.push({
5054
5720
  path: mediaPath,
@@ -5067,23 +5733,23 @@ function buildVideoOverlays(steps, stepDurations, baseDir) {
5067
5733
  }
5068
5734
  async function composeAndFinish(videoPath, audioPath, assPath, config, userTier = "free", mainContentDuration, trimStart = 0.5, stepTransitions, _stepSync) {
5069
5735
  header("Compose");
5070
- const configPath = join16(process.cwd(), CONFIG_FILE);
5071
- console.log(`${INDENT4}${chalk12.dim(`Config: ${formatClickablePath(configPath)}`)}`);
5736
+ const configPath = join17(process.cwd(), CONFIG_FILE);
5737
+ console.log(`${INDENT4}${chalk13.dim(`Config: ${formatClickablePath(configPath)}`)}`);
5072
5738
  const proceed = await confirm(`${INDENT4}Review config or continue?`);
5073
5739
  if (!proceed) {
5074
- console.log(chalk12.yellow(`
5740
+ console.log(chalk13.yellow(`
5075
5741
  Edit ${CONFIG_FILE} and run again.
5076
5742
  `));
5077
5743
  process.exit(0);
5078
5744
  }
5079
5745
  const composeTask = task("Building final video");
5080
- await mkdir7(config.output.dir, { recursive: true });
5746
+ await mkdir8(config.output.dir, { recursive: true });
5081
5747
  const suffix = config.lang ? `-${config.lang}` : "";
5082
- const finalPath = join16(config.output.dir, `demo${suffix}.${config.video.format}`);
5748
+ const finalPath = join17(config.output.dir, `demo${suffix}.${config.video.format}`);
5083
5749
  const videoDuration = await getMediaDuration(videoPath) / 1e3;
5084
- const hasVoiceover = audioPath && existsSync13(audioPath);
5750
+ const hasVoiceover = audioPath && existsSync14(audioPath);
5085
5751
  const musicPath = config.music?.path ? isAbsolute4(config.music.path) ? config.music.path : resolve7(config.output.dir, config.music.path) : "";
5086
- const hasMusic = musicPath && existsSync13(musicPath);
5752
+ const hasMusic = musicPath && existsSync14(musicPath);
5087
5753
  try {
5088
5754
  await composeVideo({
5089
5755
  videoPath,
@@ -5122,7 +5788,7 @@ async function speedUpToFitLimit(finalPath, config, limitSecs = 60) {
5122
5788
  if (duration <= limitSecs * 1e3) return finalPath;
5123
5789
  const speedFactor = Math.ceil(duration / (limitSecs * 1e3) * 10) / 10;
5124
5790
  console.log();
5125
- console.log(chalk12.yellow(` Video is ${(duration / 1e3).toFixed(0)}s - over ${limitSecs}s`));
5791
+ console.log(chalk13.yellow(` Video is ${(duration / 1e3).toFixed(0)}s - over ${limitSecs}s`));
5126
5792
  const speedUp = await confirm(` Speed up ${speedFactor}x to fit under ${limitSecs}s?`);
5127
5793
  if (!speedUp) return finalPath;
5128
5794
  const speedTask = task(`Speeding up ${speedFactor}x`);
@@ -5145,6 +5811,21 @@ async function speedUpToFitLimit(finalPath, config, limitSecs = 60) {
5145
5811
 
5146
5812
  // src/commands/phases/video-phase.ts
5147
5813
  var INDENT5 = " ";
5814
+ function resolveOutro(config, userTier) {
5815
+ if (userTier !== "free") {
5816
+ const custom = config.video.outro?.trim();
5817
+ if (!custom) return void 0;
5818
+ const path = resolve8(custom);
5819
+ if (existsSync15(path)) return path;
5820
+ info(`${INDENT5}\u26A0 outro not found: ${path}`);
5821
+ return void 0;
5822
+ }
5823
+ const scriptDir = getScriptDir();
5824
+ const bundled = join18(scriptDir, "assets", "qp.wm");
5825
+ if (existsSync15(bundled)) return bundled;
5826
+ const fallback = join18(scriptDir, "..", "dist", "assets", "qp.wm");
5827
+ return existsSync15(fallback) ? fallback : void 0;
5828
+ }
5148
5829
  async function runVideoPhase(params) {
5149
5830
  let { stepAudio } = params;
5150
5831
  const { plan, config, voiceoverPath, workDir, captions, userTier } = params;
@@ -5163,7 +5844,7 @@ async function runVideoPhase(params) {
5163
5844
  mediaDuration = (trimEnd - trimStart2) * 1e3;
5164
5845
  } else {
5165
5846
  const mediaPath = resolve8(s.value);
5166
- if (existsSync14(mediaPath)) {
5847
+ if (existsSync15(mediaPath)) {
5167
5848
  try {
5168
5849
  const fullDuration = await getMediaDuration(mediaPath);
5169
5850
  const start = (trimStart2 ?? 0) * 1e3;
@@ -5175,7 +5856,7 @@ async function runVideoPhase(params) {
5175
5856
  }
5176
5857
  } else {
5177
5858
  const mediaPath = resolve8(s.value);
5178
- if (existsSync14(mediaPath)) {
5859
+ if (existsSync15(mediaPath)) {
5179
5860
  try {
5180
5861
  mediaDuration = await getMediaDuration(mediaPath);
5181
5862
  } catch {
@@ -5188,7 +5869,7 @@ async function runVideoPhase(params) {
5188
5869
  })
5189
5870
  );
5190
5871
  const recordingPath = getRecordingPath(config.output.dir);
5191
- const hasExistingRecording = existingRecordingMeta && existsSync14(recordingPath);
5872
+ const hasExistingRecording = existingRecordingMeta && existsSync15(recordingPath);
5192
5873
  let videoPath = recordingPath;
5193
5874
  const firstStep = plan.steps[0];
5194
5875
  const firstStepIsVideo = firstStep && (firstStep.action === "video" || firstStep.action === "media");
@@ -5228,7 +5909,7 @@ async function runVideoPhase(params) {
5228
5909
  if (videoOverlays.length > 0) {
5229
5910
  const spliceTask = task("Splicing videos into recording");
5230
5911
  try {
5231
- const splicedPath = join17(workDir, "spliced.mp4");
5912
+ const splicedPath = join18(workDir, "spliced.mp4");
5232
5913
  await spliceVideos({
5233
5914
  inputPath: videoPath,
5234
5915
  outputPath: splicedPath,
@@ -5248,8 +5929,8 @@ async function runVideoPhase(params) {
5248
5929
  if (stepTransitions.length > 0) {
5249
5930
  const transitionTask = task("Applying transitions");
5250
5931
  try {
5251
- const transitionedPath = join17(workDir, "transitioned.mp4");
5252
- const videoDuration = alignedStepDurations.reduce((a, b) => a + b, 0) / 1e3;
5932
+ const transitionedPath = join18(workDir, "transitioned.mp4");
5933
+ const videoDuration = await getMediaDuration(videoPath) / 1e3;
5253
5934
  await applyTransitions({
5254
5935
  inputVideo: videoPath,
5255
5936
  outputVideo: transitionedPath,
@@ -5267,30 +5948,24 @@ async function runVideoPhase(params) {
5267
5948
  const assPath = await generateASS(plan, stepAudio, workDir, config, captions);
5268
5949
  let videoToCompose = videoPath;
5269
5950
  let mainContentDuration;
5270
- if (userTier === "free") {
5271
- const scriptDir = getScriptDir();
5272
- let outroPath = join17(scriptDir, "assets", "qp.wm");
5273
- if (!existsSync14(outroPath)) {
5274
- outroPath = join17(scriptDir, "..", "dist", "assets", "qp.wm");
5275
- }
5276
- if (existsSync14(outroPath)) {
5277
- try {
5278
- const mainDuration = await getMediaDuration(videoPath);
5279
- mainContentDuration = mainDuration / 1e3 - trimStart;
5280
- const combinedPath = join17(workDir, "combined-with-outro.mp4");
5281
- await concatVideoWithOutro({
5282
- inputPath: videoPath,
5283
- outroPath,
5284
- outputPath: combinedPath,
5285
- width: config.video.width,
5286
- height: config.video.height,
5287
- preset: config.video.preset
5288
- });
5289
- videoToCompose = combinedPath;
5290
- } catch (err) {
5291
- await logError("Concat Outro", err);
5292
- mainContentDuration = void 0;
5293
- }
5951
+ const outroPath = resolveOutro(config, userTier);
5952
+ if (outroPath) {
5953
+ try {
5954
+ const mainDuration = await getMediaDuration(videoPath);
5955
+ mainContentDuration = mainDuration / 1e3 - trimStart;
5956
+ const combinedPath = join18(workDir, "combined-with-outro.mp4");
5957
+ await concatVideoWithOutro({
5958
+ inputPath: videoPath,
5959
+ outroPath,
5960
+ outputPath: combinedPath,
5961
+ width: config.video.width,
5962
+ height: config.video.height,
5963
+ preset: config.video.preset
5964
+ });
5965
+ videoToCompose = combinedPath;
5966
+ } catch (err) {
5967
+ await logError("Concat Outro", err);
5968
+ mainContentDuration = void 0;
5294
5969
  }
5295
5970
  }
5296
5971
  let finalPath = await composeAndFinish(
@@ -5325,18 +6000,18 @@ async function runVideoPhase(params) {
5325
6000
 
5326
6001
  // src/commands/phases/voiceover-phase.ts
5327
6002
  init_lib();
5328
- import { existsSync as existsSync15 } from "fs";
5329
- import { join as join18 } from "path";
5330
- import chalk13 from "chalk";
6003
+ import { existsSync as existsSync16 } from "fs";
6004
+ import { join as join19 } from "path";
6005
+ import chalk14 from "chalk";
5331
6006
  var INDENT6 = " ";
5332
6007
  async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5333
6008
  if (fresh) {
5334
6009
  const filesToDelete = ["voiceover.json", "voiceover.wav", "recording.meta.json"];
5335
6010
  for (const file of filesToDelete) {
5336
- const filePath = join18(config.output.dir, file);
5337
- if (existsSync15(filePath)) {
5338
- const { unlink: unlink3 } = await import("fs/promises");
5339
- await unlink3(filePath).catch(() => {
6011
+ const filePath = join19(config.output.dir, file);
6012
+ if (existsSync16(filePath)) {
6013
+ const { unlink: unlink4 } = await import("fs/promises");
6014
+ await unlink4(filePath).catch(() => {
5340
6015
  });
5341
6016
  }
5342
6017
  }
@@ -5344,8 +6019,13 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5344
6019
  }
5345
6020
  const voice = resolveVoice(config);
5346
6021
  const existingMeta = await loadVoiceoverMeta(config.output.dir);
5347
- const voiceoverFile = join18(config.output.dir, "voiceover.wav");
5348
- const hasExistingVoiceover = existingMeta && existsSync15(voiceoverFile);
6022
+ const voiceoverFile = join19(config.output.dir, "voiceover.wav");
6023
+ const voiceoverOnDisk = existingMeta && existsSync16(voiceoverFile);
6024
+ const staleContainer = voiceoverOnDisk ? !await isHonestWav(voiceoverFile) : false;
6025
+ if (staleContainer) {
6026
+ info("Existing voiceover has a stale container, regenerating");
6027
+ }
6028
+ const hasExistingVoiceover = voiceoverOnDisk && !staleContainer;
5349
6029
  const voiceMatches = !existingMeta || !voice || existingMeta.voice === voice;
5350
6030
  let voiceoverPath;
5351
6031
  let stepAudio;
@@ -5368,9 +6048,9 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5368
6048
  const reuseCheck = canReuseVoiceover(plan.steps, voice, existingMeta);
5369
6049
  if (reuseCheck.canReuse) {
5370
6050
  console.log();
5371
- console.log(chalk13.dim(` Found existing voiceover in ${config.output.dir}`));
6051
+ console.log(chalk14.dim(` Found existing voiceover in ${config.output.dir}`));
5372
6052
  if (reuseCheck.reason) {
5373
- console.log(chalk13.dim(` ${reuseCheck.reason}`));
6053
+ console.log(chalk14.dim(` ${reuseCheck.reason}`));
5374
6054
  }
5375
6055
  const reuse = await confirm(`${INDENT6}Reuse existing voiceover?`);
5376
6056
  if (reuse) {
@@ -5402,31 +6082,31 @@ async function runVoiceoverPhase(plan, config, workDir, skipVoice, fresh) {
5402
6082
  }
5403
6083
 
5404
6084
  // src/commands/utils/chrome-extension.ts
5405
- import { existsSync as existsSync16, readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
6085
+ import { existsSync as existsSync17, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "fs";
5406
6086
  import { homedir as homedir3 } from "os";
5407
- import { join as join19 } from "path";
6087
+ import { join as join20 } from "path";
5408
6088
  function resolveChromeExtension(extensionId) {
5409
6089
  const platform = process.platform;
5410
6090
  const possibleDirs = [];
5411
6091
  const getChromeExtDirs = (userDataPath) => {
5412
6092
  const dirs = [];
5413
6093
  try {
5414
- const profiles = readdirSync3(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
6094
+ const profiles = readdirSync4(userDataPath).filter((p) => p === "Default" || p.startsWith("Profile "));
5415
6095
  for (const profile of profiles) {
5416
- dirs.push(join19(userDataPath, profile, "Extensions"));
6096
+ dirs.push(join20(userDataPath, profile, "Extensions"));
5417
6097
  }
5418
6098
  } catch {
5419
- dirs.push(join19(userDataPath, "Default", "Extensions"));
6099
+ dirs.push(join20(userDataPath, "Default", "Extensions"));
5420
6100
  }
5421
6101
  return dirs;
5422
6102
  };
5423
6103
  if (platform === "win32") {
5424
- possibleDirs.push(...getChromeExtDirs(join19(homedir3(), "AppData", "Local", "Google", "Chrome", "User Data")));
6104
+ possibleDirs.push(...getChromeExtDirs(join20(homedir3(), "AppData", "Local", "Google", "Chrome", "User Data")));
5425
6105
  } else if (platform === "darwin") {
5426
- possibleDirs.push(...getChromeExtDirs(join19(homedir3(), "Library", "Application Support", "Google", "Chrome")));
6106
+ possibleDirs.push(...getChromeExtDirs(join20(homedir3(), "Library", "Application Support", "Google", "Chrome")));
5427
6107
  } else {
5428
6108
  try {
5429
- const windowsUsers = readdirSync3("/mnt/c/Users").filter(
6109
+ const windowsUsers = readdirSync4("/mnt/c/Users").filter(
5430
6110
  (u) => !["All Users", "Default", "Default User", "Public", "desktop.ini"].includes(u)
5431
6111
  );
5432
6112
  for (const user of windowsUsers) {
@@ -5434,22 +6114,22 @@ function resolveChromeExtension(extensionId) {
5434
6114
  }
5435
6115
  } catch {
5436
6116
  }
5437
- possibleDirs.push(...getChromeExtDirs(join19(homedir3(), ".config", "google-chrome")));
6117
+ possibleDirs.push(...getChromeExtDirs(join20(homedir3(), ".config", "google-chrome")));
5438
6118
  }
5439
6119
  for (const chromeExtDir of possibleDirs) {
5440
- const extDir = join19(chromeExtDir, extensionId);
5441
- if (!existsSync16(extDir)) continue;
6120
+ const extDir = join20(chromeExtDir, extensionId);
6121
+ if (!existsSync17(extDir)) continue;
5442
6122
  try {
5443
- const versions = readdirSync3(extDir).filter((f) => !f.startsWith("."));
6123
+ const versions = readdirSync4(extDir).filter((f) => !f.startsWith("."));
5444
6124
  if (versions.length === 0) continue;
5445
6125
  const latestVersion = versions.sort().pop();
5446
- const extPath = join19(extDir, latestVersion);
6126
+ const extPath = join20(extDir, latestVersion);
5447
6127
  let name = extensionId;
5448
6128
  let popupPath = "popup.html";
5449
- const manifestPath = join19(extPath, "manifest.json");
5450
- if (existsSync16(manifestPath)) {
6129
+ const manifestPath = join20(extPath, "manifest.json");
6130
+ if (existsSync17(manifestPath)) {
5451
6131
  try {
5452
- const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
6132
+ const manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
5453
6133
  if (manifest.name && !manifest.name.startsWith("__MSG_")) {
5454
6134
  name = manifest.name;
5455
6135
  }
@@ -5480,16 +6160,23 @@ async function recordCommand(url, options = {}) {
5480
6160
  const extensionId = url.slice(7);
5481
6161
  chromeExtension = resolveChromeExtension(extensionId);
5482
6162
  if (!chromeExtension) {
5483
- console.log(chalk14.red(`
6163
+ console.log(chalk15.red(`
5484
6164
  \u2717 Chrome extension not found: ${extensionId}`));
5485
- console.log(chalk14.dim(` Make sure the extension is installed in Chrome
6165
+ console.log(chalk15.dim(` Make sure the extension is installed in Chrome
5486
6166
  `));
5487
6167
  process.exit(1);
5488
6168
  }
5489
6169
  config.browser = { extension: chromeExtension.path };
5490
6170
  url = chromeExtension.popupUrl;
5491
- console.log(chalk14.dim(` Extension: ${chromeExtension.name}`));
5492
- console.log(chalk14.dim(` Path: ${chromeExtension.path}`));
6171
+ console.log(chalk15.dim(` Extension: ${chromeExtension.name}`));
6172
+ console.log(chalk15.dim(` Path: ${chromeExtension.path}`));
6173
+ }
6174
+ if (!chromeExtension) {
6175
+ const startUrl = resolveStartUrl(url);
6176
+ if (startUrl !== url) {
6177
+ console.log(chalk15.dim(` Signed-in start page: ${startUrl}`));
6178
+ url = startUrl;
6179
+ }
5493
6180
  }
5494
6181
  config.output.dir = chromeExtension ? `./${chromeExtension.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-demo` : getOutputDirFromUrl(url);
5495
6182
  const userData = await loadUserData();
@@ -5502,20 +6189,20 @@ async function recordCommand(url, options = {}) {
5502
6189
  if (!authResult) return;
5503
6190
  const { userEmail, userTier, localVideoCount } = authResult;
5504
6191
  if (configCreated) {
5505
- const configPath = join20(process.cwd(), CONFIG_FILE);
6192
+ const configPath = join21(process.cwd(), CONFIG_FILE);
5506
6193
  console.log();
5507
- console.log(chalk14.green(` \u2714 Created ${CONFIG_FILE}`));
5508
- console.log(chalk14.dim(` ${configPath}`));
6194
+ console.log(chalk15.green(` \u2714 Created ${CONFIG_FILE}`));
6195
+ console.log(chalk15.dim(` ${configPath}`));
5509
6196
  console.log();
5510
- console.log(chalk14.cyan(" Review the config file to customize:"));
5511
- console.log(chalk14.dim(" \u2022 lang: Voice language (en, es, ar, zh...)"));
5512
- console.log(chalk14.dim(" \u2022 video: Resolution, quality, effects"));
5513
- console.log(chalk14.dim(" \u2022 music: Background music path and volume"));
5514
- console.log(chalk14.dim(" \u2022 output: Output directory"));
6197
+ console.log(chalk15.cyan(" Review the config file to customize:"));
6198
+ console.log(chalk15.dim(" \u2022 lang: Voice language (en, es, ar, zh...)"));
6199
+ console.log(chalk15.dim(" \u2022 video: Resolution, quality, effects"));
6200
+ console.log(chalk15.dim(" \u2022 music: Background music path and volume"));
6201
+ console.log(chalk15.dim(" \u2022 output: Output directory"));
5515
6202
  console.log();
5516
6203
  const proceed = await confirm(`${INDENT2}Proceed with default settings?`);
5517
6204
  if (!proceed) {
5518
- console.log(chalk14.yellow(`
6205
+ console.log(chalk15.yellow(`
5519
6206
  Edit ${CONFIG_FILE} and run again.
5520
6207
  `));
5521
6208
  process.exit(0);
@@ -5524,16 +6211,16 @@ async function recordCommand(url, options = {}) {
5524
6211
  if (langOverride) config.lang = langOverride;
5525
6212
  if (config.music?.path) {
5526
6213
  const musicPath = isAbsolute5(config.music.path) ? config.music.path : resolve9(config.output.dir, config.music.path);
5527
- if (!existsSync17(musicPath)) {
6214
+ if (!existsSync18(musicPath)) {
5528
6215
  console.log();
5529
- console.log(chalk14.yellow(` \u26A0 Music file not found: ${config.music.path}`));
5530
- console.log(chalk14.dim(` Config: ${join20(process.cwd(), CONFIG_FILE)}`));
6216
+ console.log(chalk15.yellow(` \u26A0 Music file not found: ${config.music.path}`));
6217
+ console.log(chalk15.dim(` Config: ${join21(process.cwd(), CONFIG_FILE)}`));
5531
6218
  console.log();
5532
6219
  const proceed = await confirm(`${INDENT2}Continue without background music?`);
5533
6220
  if (!proceed) {
5534
- console.log(chalk14.yellow(`
6221
+ console.log(chalk15.yellow(`
5535
6222
  Edit music.path in config and run again.`));
5536
- console.log(chalk14.dim(` ${join20(process.cwd(), CONFIG_FILE)}
6223
+ console.log(chalk15.dim(` ${join21(process.cwd(), CONFIG_FILE)}
5537
6224
  `));
5538
6225
  process.exit(0);
5539
6226
  }
@@ -5543,10 +6230,10 @@ async function recordCommand(url, options = {}) {
5543
6230
  const captions = await loadCaptions();
5544
6231
  const workDir = await prepareWorkDir();
5545
6232
  const targetLang = config.lang || "en";
5546
- const planPath = join20(config.output.dir, `plan-${targetLang}.json`);
5547
- const legacyPlanPath = join20(config.output.dir, "plan.json");
6233
+ const planPath = join21(config.output.dir, `plan-${targetLang}.json`);
6234
+ const legacyPlanPath = join21(config.output.dir, "plan.json");
5548
6235
  const isExtensionUrl = url.startsWith("chrome-extension://");
5549
- await mkdir8(config.output.dir, { recursive: true });
6236
+ await mkdir9(config.output.dir, { recursive: true });
5550
6237
  await setLogFilePath(config.output.dir);
5551
6238
  try {
5552
6239
  const plan = await runPlanPhase({
@@ -5557,7 +6244,8 @@ async function recordCommand(url, options = {}) {
5557
6244
  ...description !== void 0 && { description },
5558
6245
  planPath,
5559
6246
  legacyPlanPath,
5560
- targetLang
6247
+ targetLang,
6248
+ ...fresh !== void 0 && { fresh }
5561
6249
  });
5562
6250
  const validatedPlan = await runValidationPhase({
5563
6251
  plan,
@@ -5582,49 +6270,58 @@ async function recordCommand(url, options = {}) {
5582
6270
  await saveUserData({ localVideoCount: newCount });
5583
6271
  const remaining = FREE_VIDEO_LIMIT - newCount;
5584
6272
  if (remaining > 0) {
5585
- console.log(chalk14.dim(` ${remaining} free video${remaining === 1 ? "" : "s"} remaining`));
6273
+ console.log(chalk15.dim(` ${remaining} free video${remaining === 1 ? "" : "s"} remaining`));
5586
6274
  }
5587
6275
  }
5588
6276
  console.log();
5589
- console.log(chalk14.bold.green(" \u2713 Done!"));
6277
+ console.log(chalk15.bold.green(" \u2713 Done!"));
5590
6278
  console.log();
5591
- console.log(chalk14.cyan(` ${formatClickablePath(finalPath)}`));
6279
+ console.log(chalk15.cyan(` ${formatClickablePath(finalPath)}`));
5592
6280
  console.log();
5593
6281
  return finalPath;
5594
6282
  } catch (err) {
5595
6283
  await logError("Record Command", err);
5596
6284
  if (rethrow) throw err;
5597
6285
  console.log();
5598
- console.log(chalk14.red(` Error: ${err instanceof Error ? err.message : "Unknown error"}`));
5599
- console.log(chalk14.dim(` See ${logFilePath} for details`));
6286
+ console.log(chalk15.red(` Error: ${err instanceof Error ? err.message : "Unknown error"}`));
6287
+ console.log(chalk15.dim(` See ${logFilePath} for details`));
5600
6288
  process.exit(1);
5601
6289
  }
5602
6290
  }
5603
6291
 
5604
6292
  // src/commands/script-cmd.ts
5605
6293
  import { readFile as readFile8 } from "fs/promises";
5606
- import { join as join21 } from "path";
5607
- import chalk15 from "chalk";
6294
+ import { join as join22 } from "path";
6295
+ import chalk16 from "chalk";
5608
6296
 
5609
6297
  // src/ai/narrate.ts
5610
6298
  init_lib();
5611
- async function generatePlanFromScript(captions, url, title, pageElementsText, lang, logError2, userEmail, tier = "free") {
6299
+ var SCRIPT_ACTIONS = /* @__PURE__ */ new Set(["click", "type", "scroll", "wait", "select", "check", "navigate"]);
6300
+ var NEEDS_TARGET = /* @__PURE__ */ new Set(["click", "type", "select", "check", "navigate"]);
6301
+ function isUsableStep(step) {
6302
+ if (!step?.action || !SCRIPT_ACTIONS.has(step.action)) return false;
6303
+ if (NEEDS_TARGET.has(step.action) && !step.target) return false;
6304
+ if (step.action === "navigate" && !/^https?:\/\//.test(step.target ?? "")) return false;
6305
+ return true;
6306
+ }
6307
+ async function generatePlanFromScript(captions, url, title, pageElementsText, lang, logError2, userEmail, tier = "free", task2) {
5612
6308
  const systemPrompt = `You map narration sentences to web UI actions.
5613
- Given page elements and a script, return a JSON demo plan.
6309
+ Given page elements and a numbered script, return one action per script line.
5614
6310
 
5615
6311
  RULES:
5616
- - Return ONLY valid JSON
5617
- - Preserve captions EXACTLY as given \u2014 never alter the text
6312
+ - Return ONLY valid JSON, no explanation, no captions
6313
+ - Return exactly one entry per script line, in order, with "line" set to that line's number
5618
6314
  - Use page elements to pick realistic actions/targets
5619
- - If a caption doesn't match a specific UI action, use { "action": "wait" }
6315
+ - If a line doesn't match a specific UI action, use { "action": "wait" }
6316
+ - Keep every target and value short
5620
6317
 
5621
6318
  ACTION FORMATS:
5622
- \u2022 click: { "action": "click", "target": "button text" }
5623
- \u2022 type: { "action": "type", "target": "input selector", "value": "text from caption" }
5624
- \u2022 scroll: { "action": "scroll", "value": "up|down" }
5625
- \u2022 wait: { "action": "wait" }
6319
+ \u2022 click: { "line": 1, "action": "click", "target": "button text" }
6320
+ \u2022 type: { "line": 2, "action": "type", "target": "input selector", "value": "text to type" }
6321
+ \u2022 scroll: { "line": 3, "action": "scroll", "value": "up|down" }
6322
+ \u2022 wait: { "line": 4, "action": "wait" }
5626
6323
 
5627
- OUTPUT: full plan JSON with url, title, description, steps[]`;
6324
+ OUTPUT: {"description":"one short sentence","steps":[ ... ]}`;
5628
6325
  const scriptLines = captions.map((c, i) => `${i + 1}. "${c}"`).join("\n");
5629
6326
  const userMessage = `URL: ${url}
5630
6327
  App: ${title}
@@ -5632,48 +6329,108 @@ App: ${title}
5632
6329
  Available page elements:
5633
6330
  ${pageElementsText}
5634
6331
 
5635
- Narration script (map each line to a UI action, preserve caption text exactly):
6332
+ Narration script (map each line to a UI action):
5636
6333
  ${scriptLines}
5637
6334
 
5638
- Return the full plan JSON.`;
5639
- const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier);
5640
- const jsonMatch = content.match(/\{[\s\S]*\}/);
5641
- if (!jsonMatch) throw new Error("No JSON in AI script response");
5642
- const plan = JSON.parse(jsonMatch[0]);
5643
- plan.url = url;
5644
- if (lang !== void 0) plan.lang = lang;
5645
- plan.steps = plan.steps.map(({ duration: _d, ...s }, i) => ({
5646
- ...s,
5647
- id: i + 1,
5648
- caption: captions[i] ?? s.caption ?? ""
5649
- }));
5650
- return plan;
6335
+ Return JSON with a "description" and a "steps" array of exactly ${captions.length} actions.`;
6336
+ const content = await callGroq(systemPrompt, userMessage, logError2, userEmail, tier, "narration");
6337
+ const parsed = await parseAIJsonWithRepair(content, {
6338
+ logError: logError2,
6339
+ task: task2,
6340
+ userEmail,
6341
+ tier,
6342
+ shape: "The JSON should have: description (string), steps (array of {line, action, target, value}).",
6343
+ context: "Script Plan"
6344
+ });
6345
+ const raw = Array.isArray(parsed?.steps) ? parsed.steps : [];
6346
+ const byLine = /* @__PURE__ */ new Map();
6347
+ raw.forEach((step, i) => {
6348
+ if (!step || typeof step !== "object") return;
6349
+ const line = typeof step.line === "number" && step.line >= 1 ? step.line : i + 1;
6350
+ if (!byLine.has(line)) byLine.set(line, step);
6351
+ });
6352
+ const steps = captions.map((caption, i) => {
6353
+ const mapped = byLine.get(i + 1);
6354
+ if (!isUsableStep(mapped)) {
6355
+ return { id: i + 1, action: "wait", caption };
6356
+ }
6357
+ return {
6358
+ id: i + 1,
6359
+ action: mapped.action,
6360
+ caption,
6361
+ ...mapped.target ? { target: mapped.target } : {},
6362
+ ...mapped.value ? { value: mapped.value } : {}
6363
+ };
6364
+ });
6365
+ return {
6366
+ url,
6367
+ title,
6368
+ description: typeof parsed?.description === "string" ? parsed.description : title,
6369
+ ...lang !== void 0 ? { lang } : {},
6370
+ steps
6371
+ };
5651
6372
  }
5652
6373
 
5653
6374
  // src/commands/script-cmd.ts
5654
6375
  init_lib();
5655
6376
  init_config_loader();
6377
+ async function reusablePlan(planPath, lines) {
6378
+ try {
6379
+ const plan = JSON.parse(await readFile8(planPath, "utf-8"));
6380
+ if (!Array.isArray(plan.steps)) return null;
6381
+ if (plan.steps.length < lines.length) return null;
6382
+ const matches = lines.every((line, i) => plan.steps[i]?.caption === line);
6383
+ return matches ? plan : null;
6384
+ } catch {
6385
+ return null;
6386
+ }
6387
+ }
5656
6388
  async function scriptCommand(url, scriptPath, lang) {
6389
+ url = resolveStartUrl(url);
5657
6390
  const { config, workDir, captions } = await initCommandEnv(url, lang);
6391
+ const userData = await loadUserData();
6392
+ const userTier = userData.email ? await getTierByEmail(userData.email) : "free";
5658
6393
  header("Script");
5659
6394
  const raw = await readFile8(scriptPath, "utf-8");
5660
6395
  const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
5661
6396
  if (lines.length === 0) {
5662
- console.log(chalk15.red(" Script file is empty or has no non-comment lines"));
6397
+ console.log(chalk16.red(" Script file is empty or has no non-comment lines"));
5663
6398
  process.exit(1);
5664
6399
  }
5665
6400
  info(`${lines.length} captions from script`);
6401
+ const maxSteps = config.plan?.maxSteps ?? 8;
6402
+ if (lines.length > maxSteps) {
6403
+ console.log(
6404
+ chalk16.yellow(` \u26A0 ${lines.length} script lines exceeds plan.maxSteps (${maxSteps}) \u2014 recording all of them.`)
6405
+ );
6406
+ console.log(chalk16.dim(` Raise plan.maxSteps in quickpeek.config.json to silence this, or split the script.`));
6407
+ }
5666
6408
  header("Analyze");
5667
6409
  const crawlTask = task("Crawling page for UI elements");
5668
- const { elements } = await crawlPage(url, () => crawlTask);
6410
+ const { elements } = await crawlPage(url, () => crawlTask, findAuthState(url));
5669
6411
  const elementsText = elements.slice(0, 30).map((e) => ` ${e.action}: ${e.text} (${e.selector})`).join("\n");
5670
6412
  header("Plan");
5671
6413
  const planTask = task("Mapping script to UI actions");
5672
6414
  const appTitle = new URL(url).hostname.replace(/^www\./, "");
5673
- const plan = await generatePlanFromScript(lines, url, appTitle, elementsText, lang, logError);
5674
- const planPath = join21(config.output.dir, `plan-${lang ?? "en"}.json`);
5675
- await savePlan(plan, planPath);
5676
- planTask.succeed(`${plan.steps.length} steps planned`);
6415
+ const planPath = join22(config.output.dir, `plan-${lang ?? "en"}.json`);
6416
+ let plan = await reusablePlan(planPath, lines);
6417
+ if (plan) {
6418
+ planTask.succeed(`${plan.steps.length} steps reused from existing plan`);
6419
+ } else {
6420
+ plan = await generatePlanFromScript(
6421
+ lines,
6422
+ url,
6423
+ appTitle,
6424
+ elementsText,
6425
+ lang,
6426
+ logError,
6427
+ userData.email,
6428
+ userTier,
6429
+ () => planTask
6430
+ );
6431
+ await savePlan(plan, planPath);
6432
+ planTask.succeed(`${plan.steps.length} steps planned`);
6433
+ }
5677
6434
  header("Voiceover");
5678
6435
  const { voiceoverPath, stepAudio } = await generateVoiceover(
5679
6436
  plan.steps,
@@ -5689,14 +6446,116 @@ async function scriptCommand(url, scriptPath, lang) {
5689
6446
  const { videoPath } = await recordSession(plan, config, stepDurations, workDir, config.output.dir, task, logError);
5690
6447
  header("Compose");
5691
6448
  const assPath = await generateASS(plan, stepAudio, workDir, config, captions);
5692
- const finalPath = await composeAndFinish(videoPath, voiceoverPath, assPath, config, "free");
6449
+ const finalPath = await composeAndFinish(videoPath, voiceoverPath, assPath, config, userTier);
5693
6450
  console.log();
5694
- console.log(chalk15.bold.green(" Done!"));
5695
- console.log(chalk15.cyan(` ${finalPath}`));
6451
+ console.log(chalk16.bold.green(" Done!"));
6452
+ console.log(chalk16.cyan(` ${finalPath}`));
5696
6453
  }
5697
6454
 
5698
6455
  // src/mcp/tools.ts
5699
6456
  init_SparkStripe_feature_gating();
6457
+
6458
+ // src/mcp/delegate.ts
6459
+ init_SparkStripe_feature_gating();
6460
+ var STEPS = {
6461
+ plan: {
6462
+ field: "plan",
6463
+ shape: "the demo plan, as the JSON object the instructions describe"
6464
+ },
6465
+ fix: {
6466
+ field: "fix",
6467
+ shape: "the corrected steps, as the JSON object the instructions describe"
6468
+ },
6469
+ narration: {
6470
+ field: "narration",
6471
+ shape: "the narration, as the JSON object the instructions describe"
6472
+ }
6473
+ };
6474
+ function textResult(text) {
6475
+ return { content: [{ type: "text", text }] };
6476
+ }
6477
+ function section(e, index) {
6478
+ const { field, shape } = STEPS[e.step];
6479
+ return [`### ${index}. ${field}`, ``, e.system, ``, e.prompt, ``, `Answer as \`${field}\`: ${shape}`].join("\n");
6480
+ }
6481
+ function delegatedBrief(pending2) {
6482
+ const tool = pending2[0]?.tool ?? "this tool";
6483
+ const fields = pending2.map((e) => STEPS[e.step].field);
6484
+ return textResult(
6485
+ [
6486
+ `\`${tool}\` needs ${pending2.length === 1 ? "a piece of" : `${pending2.length} pieces of`} writing,`,
6487
+ `and you are a stronger model than the one this CLI would relay to, so it is yours to`,
6488
+ `do. QuickPeek never generates on your behalf.`,
6489
+ ``,
6490
+ ...pending2.map((e, i) => `${section(e, i + 1)}
6491
+ `),
6492
+ `## Next`,
6493
+ ``,
6494
+ `Call \`${tool}\` again with the same arguments plus \`ai\`:`,
6495
+ ``,
6496
+ "```json",
6497
+ `{ "ai": { ${fields.map((f) => `"${f}": ...`).join(", ")} } }`,
6498
+ "```",
6499
+ ``,
6500
+ `Each value is used verbatim and no further writing is asked for.`
6501
+ ].join("\n")
6502
+ );
6503
+ }
6504
+ async function callerTier() {
6505
+ const { loadUserData: loadUserData2 } = await Promise.resolve().then(() => (init_config_loader(), config_loader_exports));
6506
+ const userData = await loadUserData2();
6507
+ if (!userData.email) return "free";
6508
+ try {
6509
+ return await getTierByEmail(userData.email);
6510
+ } catch {
6511
+ return "free";
6512
+ }
6513
+ }
6514
+ async function withDelegation(tool, args, fn) {
6515
+ const answers = args.ai ?? {};
6516
+ const tier = await callerTier();
6517
+ try {
6518
+ const result = await runInMcpTool(tool, fn, answers);
6519
+ const pending2 = pendingDelegations();
6520
+ return pending2.length ? delegatedBrief(pending2) : withTierNote(result, tier);
6521
+ } catch (e) {
6522
+ const pending2 = pendingDelegations();
6523
+ if (pending2.length) return delegatedBrief(pending2);
6524
+ if (e instanceof DelegatedGenerationError) return delegatedBrief([e]);
6525
+ throw e;
6526
+ }
6527
+ }
6528
+ function withTierNote(result, tier) {
6529
+ if (tier !== "free") return result;
6530
+ return {
6531
+ ...result,
6532
+ content: [
6533
+ ...result.content,
6534
+ {
6535
+ type: "text",
6536
+ text: `Free tier: this demo carries a watermark and the free video limit applies. Upgrade at ${pricingUrl()}`
6537
+ }
6538
+ ]
6539
+ };
6540
+ }
6541
+
6542
+ // src/mcp/elicit.ts
6543
+ var elicit = null;
6544
+ var canPrompt = null;
6545
+ function setElicit(fn) {
6546
+ elicit = fn;
6547
+ }
6548
+ function setCanElicit(fn) {
6549
+ canPrompt = fn;
6550
+ }
6551
+ function canElicit() {
6552
+ return canPrompt ? canPrompt() : elicit !== null;
6553
+ }
6554
+ async function askUser(message) {
6555
+ return elicit ? elicit(message) : null;
6556
+ }
6557
+
6558
+ // src/mcp/tools.ts
5700
6559
  var URL_PROPERTY = {
5701
6560
  url: {
5702
6561
  type: "string",
@@ -5709,15 +6568,27 @@ var LANG_PROPERTY = {
5709
6568
  description: 'ISO 639-1 language for narration and captions (e.g. "en", "fr"). Default from config.'
5710
6569
  }
5711
6570
  };
6571
+ var SKIP_AUTH_PROPERTY = {
6572
+ skip_auth: {
6573
+ type: "boolean",
6574
+ description: "Record without signing in. By default, a URL with no saved session prompts the user to sign in first so the demo covers the app, not the logged-out landing page. Pass true only for public sites with nothing behind a login, or when a logged-out demo is intended."
6575
+ }
6576
+ };
5712
6577
  var QUICKPEEK_TOOLS = [
5713
6578
  {
5714
6579
  name: "generate_demo",
5715
- description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. Pages behind a login can be demoed after the user runs `qp auth <url>` once in a terminal; the saved session is picked up automatically.",
6580
+ description: "Record a narrated demo video of a web app, end to end: an AI plans the walkthrough, Playwright drives a real browser, and the result is composed with voiceover and burned karaoke captions. This is the headline tool. It takes minutes, not seconds, because a real browser session is recorded. Returns the finished MP4 path and a file:// url. If the host has no saved session, the user is first prompted to sign in (a visible browser opens; QuickPeek never sees the password) so the demo starts inside the app, usually the dashboard, instead of the logged-out landing page. Pass skip_auth for public sites. The result says whether this recording was signed in.",
5716
6581
  inputSchema: {
5717
6582
  type: "object",
5718
6583
  properties: {
6584
+ ai: {
6585
+ type: "object",
6586
+ description: `Writing you produced from this tool's brief, keyed by the fields it asked for (e.g. {"plan": {...}}). Each value is used verbatim and no AI call is made for it. Omit on the first call: the tool will tell you what it needs. This server never generates text itself - you are the model it delegates to.`,
6587
+ additionalProperties: true
6588
+ },
5719
6589
  ...URL_PROPERTY,
5720
6590
  ...LANG_PROPERTY,
6591
+ ...SKIP_AUTH_PROPERTY,
5721
6592
  description: {
5722
6593
  type: "string",
5723
6594
  description: "What the demo should focus on, in your words. Steers the AI plan. Without it, the planner infers the story from the page itself."
@@ -5736,20 +6607,49 @@ var QUICKPEEK_TOOLS = [
5736
6607
  },
5737
6608
  {
5738
6609
  name: "generate_demo_from_script",
5739
- description: "Record a demo that follows a script you supply, one caption per line, instead of an AI-planned walkthrough. The AI still maps each line to a UI action. Use when you know exactly what the video should say and in what order.",
6610
+ description: "Record a demo that follows a script you supply, one caption per line, instead of an AI-planned walkthrough. The AI still maps each line to a UI action. Use when you know exactly what the video should say and in what order. Prompts for sign-in like generate_demo when the host has no saved session.",
5740
6611
  inputSchema: {
5741
6612
  type: "object",
5742
6613
  properties: {
6614
+ ai: {
6615
+ type: "object",
6616
+ description: `Writing you produced from this tool's brief, keyed by the fields it asked for (e.g. {"plan": {...}}). Each value is used verbatim and no AI call is made for it. Omit on the first call: the tool will tell you what it needs. This server never generates text itself - you are the model it delegates to.`,
6617
+ additionalProperties: true
6618
+ },
5743
6619
  ...URL_PROPERTY,
5744
6620
  script_path: {
5745
6621
  type: "string",
5746
6622
  description: "Path to a plain text file with one sentence or caption per line."
5747
6623
  },
5748
- ...LANG_PROPERTY
6624
+ ...LANG_PROPERTY,
6625
+ ...SKIP_AUTH_PROPERTY
5749
6626
  },
5750
6627
  required: ["url", "script_path"]
5751
6628
  }
5752
6629
  },
6630
+ {
6631
+ name: "open_in_editor",
6632
+ description: "Open a recorded demo's plan in the browser editor, where the user can reword captions, reorder steps or drop them. The plan travels in the URL fragment, so nothing is uploaded; saving in the editor writes back over the same file and the next recording picks it up. Records nothing itself.",
6633
+ inputSchema: {
6634
+ type: "object",
6635
+ properties: { ...URL_PROPERTY, ...LANG_PROPERTY },
6636
+ required: ["url"]
6637
+ }
6638
+ },
6639
+ {
6640
+ name: "start_auth",
6641
+ description: "Open a visible browser on a site so the user can sign in, once, by hand. Everything recorded afterwards starts from that session, and demos of the bare URL begin on the page the user leaves open when the session is saved \u2014 tell them to finish on their dashboard. QuickPeek never sees the password: only the resulting cookies are saved. On clients that support prompting, this saves the session as soon as the user confirms; otherwise call finish_auth when they say they are done.",
6642
+ inputSchema: {
6643
+ type: "object",
6644
+ properties: { ...URL_PROPERTY },
6645
+ required: ["url"]
6646
+ }
6647
+ },
6648
+ {
6649
+ name: "finish_auth",
6650
+ description: "Save the session from the browser window start_auth opened, and close it. Call this once the user says they have signed in.",
6651
+ inputSchema: { type: "object", properties: {} }
6652
+ },
5753
6653
  {
5754
6654
  name: "list_demos",
5755
6655
  description: "List demo videos already produced for a URL, newest first, with size and modified time. Cheap: reads the output directory, records nothing.",
@@ -5779,9 +6679,38 @@ var QUICKPEEK_TOOLS = [
5779
6679
  inputSchema: { type: "object", properties: {} }
5780
6680
  }
5781
6681
  ];
6682
+ async function ensureSignedIn(url, skipAuth) {
6683
+ if (!url.startsWith("http") || skipAuth || findAuthState(url)) return null;
6684
+ const host = new URL(url).host;
6685
+ const authRequired = () => ({
6686
+ ...jsonContent({
6687
+ status: "auth_required",
6688
+ host,
6689
+ message: `No saved sign-in for ${host}, so the demo would only show logged-out pages. Ask the user how to proceed: to demo the app signed in, call start_auth with this url, then finish_auth once they confirm, and retry. For a public site with no login, retry with skip_auth: true.`
6690
+ }),
6691
+ isError: true
6692
+ });
6693
+ if (!canElicit()) return authRequired();
6694
+ await openSignIn(url);
6695
+ const confirmed = await askUser(
6696
+ `No saved sign-in for ${host}. A browser window is open: sign in there and leave it on the page the demo should start from (usually your dashboard), then confirm. Decline to record logged out.`
6697
+ );
6698
+ if (confirmed === null) {
6699
+ await cancelSignIn();
6700
+ return authRequired();
6701
+ }
6702
+ if (confirmed) {
6703
+ await saveSignIn();
6704
+ return null;
6705
+ }
6706
+ await cancelSignIn();
6707
+ return null;
6708
+ }
5782
6709
  async function handleGenerateDemo(args) {
5783
6710
  const url = resolveUrl(args.url);
5784
6711
  return withSilencedStdout(async () => {
6712
+ const authGate = await ensureSignedIn(url, args.skip_auth);
6713
+ if (authGate) return authGate;
5785
6714
  const output = await recordCommand(url, {
5786
6715
  langOverride: args.lang,
5787
6716
  description: args.description,
@@ -5793,7 +6722,11 @@ async function handleGenerateDemo(args) {
5793
6722
  rethrow: true
5794
6723
  });
5795
6724
  if (!output) return errorContent("Recording finished without producing a video.");
5796
- return fileResult(output, { name: output.split("/").pop(), url_recorded: url, auth: authStatus(url) });
6725
+ return fileResult(output, {
6726
+ name: output.split("/").pop(),
6727
+ url_recorded: resolveStartUrl(url),
6728
+ auth: authStatus(url)
6729
+ });
5797
6730
  });
5798
6731
  }
5799
6732
  function authStatus(url) {
@@ -5803,23 +6736,74 @@ function authStatus(url) {
5803
6736
  return {
5804
6737
  signedIn: false,
5805
6738
  host,
5806
- note: `No saved session for ${host}, so only pages reachable while logged out were recorded. To cover pages behind the login, ask the user to run \`qp auth ${url}\` in a terminal and sign in once, then re-run with fresh: true.`
6739
+ note: `No saved session for ${host}, so only pages reachable while logged out were recorded. To cover pages behind the login, call start_auth with this url (or have the user run \`qp auth ${url}\`), then re-run with fresh: true and without skip_auth.`
5807
6740
  };
5808
6741
  }
5809
6742
  async function handleGenerateDemoFromScript(args) {
5810
6743
  const url = resolveUrl(args.url);
5811
6744
  const scriptPath = resolveInputPath(args.script_path);
5812
6745
  return withSilencedStdout(async () => {
6746
+ const authGate = await ensureSignedIn(url, args.skip_auth);
6747
+ if (authGate) return authGate;
5813
6748
  await scriptCommand(url, scriptPath, args.lang);
5814
6749
  const dir = getOutputDirFromUrl(url);
5815
6750
  const newest = newestVideo(dir);
5816
6751
  if (!newest) return errorContent(`No video found in ${dir} after running the script.`);
5817
- return fileResult(newest, { name: newest.split("/").pop(), url_recorded: url, scriptPath });
6752
+ return fileResult(newest, { name: newest.split("/").pop(), url_recorded: resolveStartUrl(url), scriptPath });
6753
+ });
6754
+ }
6755
+ async function handleOpenInEditor(args) {
6756
+ const url = resolveUrl(args.url);
6757
+ return withSilencedStdout(async () => {
6758
+ const dir = getOutputDirFromUrl(url);
6759
+ const planPath = findPlanFile(dir, args.lang);
6760
+ if (!planPath) {
6761
+ return errorContent(`No plan found in ${dir}. Record the demo first with generate_demo.`);
6762
+ }
6763
+ const parsed = parsePlanText(readFileSync7(planPath, "utf8"));
6764
+ if (!parsed.ok) return errorContent(`${planPath} cannot be opened: ${parsed.error}`);
6765
+ const base = editorBaseUrl();
6766
+ const editorUrl = planEditorUrl(base, parsed.plan);
6767
+ await openInBrowser(editorUrl ?? `${base}/edit`);
6768
+ return jsonContent({
6769
+ opened: true,
6770
+ plan_path: planPath,
6771
+ steps: parsed.plan.steps.length,
6772
+ url: editorUrl ?? `${base}/edit`,
6773
+ next_steps: editorUrl ? ["The editor opens with the plan loaded.", "Saving there writes back over plan_path; then re-record."] : [`This plan is too long for a link. Load ${planPath} in the editor by hand.`]
6774
+ });
6775
+ });
6776
+ }
6777
+ async function handleStartAuth(args) {
6778
+ const url = resolveUrl(args.url);
6779
+ return withSilencedStdout(async () => {
6780
+ await openSignIn(url);
6781
+ const confirmed = await askUser(
6782
+ `A browser window is open on ${url}. Sign in there, leave it on the page demos should start from (usually your dashboard), then confirm.`
6783
+ );
6784
+ if (confirmed === null) {
6785
+ return jsonContent({
6786
+ status: "awaiting_sign_in",
6787
+ url,
6788
+ next: "A browser window is open. Call finish_auth once the user says they are signed in."
6789
+ });
6790
+ }
6791
+ if (!confirmed) {
6792
+ await cancelSignIn();
6793
+ return jsonContent({ status: "cancelled", url, note: "Nothing was saved." });
6794
+ }
6795
+ return jsonContent({ status: "saved", ...await saveSignIn() });
6796
+ });
6797
+ }
6798
+ async function handleFinishAuth() {
6799
+ return withSilencedStdout(async () => {
6800
+ if (!pendingSignIn()) return errorContent("No sign-in window is open. Call start_auth first.");
6801
+ return jsonContent({ status: "saved", ...await saveSignIn() });
5818
6802
  });
5819
6803
  }
5820
6804
  function newestVideo(dir) {
5821
6805
  try {
5822
- const videos = readdirSync4(dir).filter((f) => f.toLowerCase().endsWith(".mp4")).map((f) => join22(dir, f)).map((p) => ({ path: p, mtime: statSync2(p).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
6806
+ const videos = readdirSync5(dir).filter((f) => f.toLowerCase().endsWith(".mp4")).map((f) => join23(dir, f)).map((p) => ({ path: p, mtime: statSync2(p).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
5823
6807
  return videos[0]?.path ?? null;
5824
6808
  } catch {
5825
6809
  return null;
@@ -5831,8 +6815,8 @@ async function handleListDemos(args) {
5831
6815
  return withSilencedStdout(async () => {
5832
6816
  let entries = [];
5833
6817
  try {
5834
- entries = readdirSync4(dir).filter((f) => f.toLowerCase().endsWith(".mp4")).map((f) => {
5835
- const s = statSync2(join22(dir, f));
6818
+ entries = readdirSync5(dir).filter((f) => f.toLowerCase().endsWith(".mp4")).map((f) => {
6819
+ const s = statSync2(join23(dir, f));
5836
6820
  return { name: f, bytes: s.size, modified: new Date(s.mtimeMs).toISOString() };
5837
6821
  }).sort((a, b) => b.modified.localeCompare(a.modified));
5838
6822
  } catch {
@@ -5876,8 +6860,11 @@ async function handleCheckAccount() {
5876
6860
  });
5877
6861
  }
5878
6862
  var QUICKPEEK_HANDLERS = {
5879
- generate_demo: handleGenerateDemo,
5880
- generate_demo_from_script: handleGenerateDemoFromScript,
6863
+ generate_demo: (args) => withDelegation("generate_demo", args, () => handleGenerateDemo(args)),
6864
+ generate_demo_from_script: (args) => withDelegation("generate_demo_from_script", args, () => handleGenerateDemoFromScript(args)),
6865
+ open_in_editor: handleOpenInEditor,
6866
+ start_auth: handleStartAuth,
6867
+ finish_auth: handleFinishAuth,
5881
6868
  list_demos: handleListDemos,
5882
6869
  get_demo_config: handleGetDemoConfig,
5883
6870
  list_voices: handleListVoices,
@@ -5905,5 +6892,7 @@ export {
5905
6892
  TOOLS,
5906
6893
  TOOL_HANDLERS,
5907
6894
  errorContent,
5908
- jsonContent
6895
+ jsonContent,
6896
+ setCanElicit,
6897
+ setElicit
5909
6898
  };