@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-release.js +7 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +183 -21
  10. package/src/commands/pull.js +9 -4
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +57 -3
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +190 -10
  16. package/src/lib/api-client.js +61 -35
  17. package/src/lib/auto-update/refresh.js +598 -0
  18. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  19. package/src/lib/auto-update/spec.js +89 -0
  20. package/src/lib/capture-engine.js +76 -2
  21. package/src/lib/capture-script-runner.js +289 -138
  22. package/src/lib/certification.js +23 -1
  23. package/src/lib/compose-context.js +156 -0
  24. package/src/lib/compose-pack.js +42 -0
  25. package/src/lib/compose-runtime.js +34 -0
  26. package/src/lib/compose-upload.js +142 -0
  27. package/src/lib/config.js +2 -2
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/ensure-browser.js +147 -0
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/release-doctor.js +11 -3
  33. package/src/lib/resolve-targets.js +60 -0
  34. package/src/lib/run-manifest.js +45 -0
  35. package/src/lib/ui-api-helpers.js +118 -0
  36. package/src/lib/ui-api.js +28 -820
  37. package/src/lib/ui-asset-cleanup.js +62 -0
  38. package/src/lib/ui-output-versions.js +165 -0
  39. package/src/lib/ui-recorder-routes.js +341 -0
  40. package/src/lib/ui-scenario-metadata.js +161 -0
  41. package/vendor/compose/dist/auto-update.cjs +5544 -0
  42. package/vendor/compose/dist/auto-update.mjs +5518 -0
  43. package/vendor/compose/dist/capture.cjs +1450 -0
  44. package/vendor/compose/dist/capture.mjs +1416 -0
  45. package/vendor/compose/dist/eligibility.cjs +5331 -0
  46. package/vendor/compose/dist/eligibility.mjs +5313 -0
  47. package/vendor/compose/dist/index.cjs +2046 -0
  48. package/vendor/compose/dist/index.mjs +1997 -0
  49. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  51. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  52. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  53. package/vendor/compose/dist/render.cjs +558 -0
  54. package/vendor/compose/dist/render.mjs +515 -0
  55. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  56. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  57. package/vendor/compose/dist/verify.cjs +3880 -0
  58. package/vendor/compose/dist/verify.mjs +3858 -0
  59. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  60. package/web/manager/dist/index.html +1 -1
  61. package/src/commands/ingest.js +0 -458
  62. package/src/commands/setup.js +0 -165
  63. package/src/lib/playwright-runner.js +0 -252
@@ -15,7 +15,10 @@ const {
15
15
  getStorageMode,
16
16
  isPlatformAvailable,
17
17
  } = require("../lib/storage-providers");
18
- const { getLatestSuccessfulRunManifest } = require("../lib/run-manifest");
18
+ const {
19
+ getLatestSuccessfulRunManifest,
20
+ getLatestUsableRunManifest,
21
+ } = require("../lib/run-manifest");
19
22
  const pkg = require("../../package.json");
20
23
 
21
24
  // Check if transactional flow should be used (R2 configured on server)
@@ -505,9 +508,9 @@ function collectAssetFilesFromDirectories(directories) {
505
508
  }
506
509
 
507
510
  function resolveManifestScopedScreenshotFiles(outputBaseDir, latestRunManifest) {
508
- if (!latestRunManifest?.success) {
511
+ if (!latestRunManifest) {
509
512
  throw new Error(
510
- "No successful run manifest is available. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
513
+ "No run manifest is available. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
511
514
  );
512
515
  }
513
516
 
@@ -518,7 +521,7 @@ function resolveManifestScopedScreenshotFiles(outputBaseDir, latestRunManifest)
518
521
 
519
522
  if (scenarioDirs.length === 0) {
520
523
  throw new Error(
521
- "The latest successful run manifest does not contain any scenario output directories.",
524
+ "The run manifest does not contain any successful scenario output directories.",
522
525
  );
523
526
  }
524
527
 
@@ -592,6 +595,11 @@ function buildPublishMetadata({
592
595
  }) {
593
596
  const scenarioDefinition = buildScenarioDefinition(scenarioConfig);
594
597
 
598
+ // Only attach git metadata when a real commit hash is available. Sending
599
+ // empty git values when the repo has no HEAD causes the platform to reject
600
+ // the batch with an opaque 400.
601
+ const hasGit = !!(gitInfo && gitInfo.commitHash);
602
+
595
603
  return {
596
604
  projectId,
597
605
  publishSessionId, // Unique ID for this CLI publish run
@@ -606,10 +614,14 @@ function buildPublishMetadata({
606
614
  publish: {
607
615
  autoApprove,
608
616
  },
609
- git: {
610
- commitHash: gitInfo.commitHash,
611
- commitMessage: gitInfo.commitMessage,
612
- },
617
+ ...(hasGit
618
+ ? {
619
+ git: {
620
+ commitHash: gitInfo.commitHash,
621
+ commitMessage: gitInfo.commitMessage,
622
+ },
623
+ }
624
+ : {}),
613
625
  cli: {
614
626
  version: pkg.version,
615
627
  captureTimestamp: new Date().toISOString(),
@@ -677,6 +689,52 @@ async function publishWithTransactionalFlow(
677
689
  }
678
690
  }
679
691
 
692
+ // ── DOM scene sidecars (MHTML bundles next to each PNG) ─────────────
693
+ // The CLI emits an .mhtml alongside each PNG when scenario.domScene !==
694
+ // false. We upload them as sidecar assets linked to the parent PNG's
695
+ // visual version via _parentVisualKey, so the server can persist the
696
+ // sidecar's s3Path on the same VisualVersion row.
697
+ const domSceneSidecars = [];
698
+ for (const f of allFiles) {
699
+ // Only attach MHTML to PNG screenshots (not videos, not other sidecars).
700
+ if (path.extname(f.path).toLowerCase() !== ".png") continue;
701
+ if (f._isThumbnail || f._isDomScene) continue;
702
+
703
+ const candidate =
704
+ (f.asset && f.asset.domScenePath) ||
705
+ f.path.replace(/\.png$/i, ".mhtml");
706
+ if (!fs.existsSync(candidate)) continue;
707
+
708
+ const sidecarStat = fs.statSync(candidate);
709
+ domSceneSidecars.push({
710
+ group: f.group,
711
+ asset: {
712
+ captureKey: f.asset.captureKey,
713
+ path: candidate,
714
+ filename: path.basename(candidate),
715
+ },
716
+ scenarioConfig: f.scenarioConfig,
717
+ key: f.key,
718
+ visualKey: f.visualKey,
719
+ path: candidate,
720
+ size: sidecarStat.size,
721
+ contentType: "multipart/related",
722
+ hash: null,
723
+ diffData: null,
724
+ thumbnailPath: null,
725
+ _isDomScene: true,
726
+ _parentVisualKey: f.visualKey,
727
+ });
728
+ }
729
+ if (domSceneSidecars.length > 0) {
730
+ console.log(
731
+ chalk.gray(
732
+ ` Attaching ${domSceneSidecars.length} DOM scene sidecar(s)...`,
733
+ ),
734
+ );
735
+ allFiles.push(...domSceneSidecars);
736
+ }
737
+
680
738
  // Generate thumbnails for video files (first frame as PNG)
681
739
  const videoExts = new Set([".mp4", ".webm", ".mov"]);
682
740
  const videoFiles = allFiles.filter((f) =>
@@ -776,7 +834,10 @@ async function publishWithTransactionalFlow(
776
834
 
777
835
  // Step 3: Upload files directly to R2 (parallel with concurrency limit)
778
836
  console.log(chalk.gray(" Uploading files directly to R2..."));
779
- const CONCURRENCY = 5;
837
+ const CONCURRENCY = Math.min(
838
+ Math.max(parseInt(process.env.RESHOT_UPLOAD_CONCURRENCY || "10", 10), 1),
839
+ 20,
840
+ );
780
841
  const uploadQueue = [...allFiles];
781
842
  const uploadResults = [];
782
843
 
@@ -818,21 +879,33 @@ async function publishWithTransactionalFlow(
818
879
 
819
880
  // Build a map of thumbnail s3Paths keyed by parent visualKey
820
881
  const thumbnailS3Paths = new Map();
882
+ // Build a map of DOM-scene MHTML s3Paths keyed by parent visualKey.
883
+ // The server attaches these to the parent VisualVersion as a sidecar.
884
+ const domSceneS3Paths = new Map();
885
+ const domSceneSizes = new Map();
821
886
  for (const result of uploadResults) {
822
- if (result.success && result.file._isThumbnail) {
887
+ if (!result.success) continue;
888
+ if (result.file._isThumbnail) {
823
889
  thumbnailS3Paths.set(result.file._parentVisualKey, result.s3Path);
890
+ } else if (result.file._isDomScene) {
891
+ domSceneS3Paths.set(result.file._parentVisualKey, result.s3Path);
892
+ domSceneSizes.set(result.file._parentVisualKey, result.file.size);
824
893
  }
825
894
  }
826
895
 
827
- // Group successful uploads by scenario/variant (skip thumbnails they're metadata)
896
+ // Group successful uploads by scenario/variant (skip thumbnails + DOM
897
+ // sidecars — they're metadata attached to their parent asset).
898
+ const failedUploadKeys = [];
828
899
  const groupMap = new Map();
829
900
  for (const result of uploadResults) {
830
901
  if (!result.success) {
831
- if (!result.file._isThumbnail) failCount++;
902
+ if (!result.file._isThumbnail && !result.file._isDomScene) {
903
+ failCount++;
904
+ failedUploadKeys.push(result.file.visualKey);
905
+ }
832
906
  continue;
833
907
  }
834
- // Thumbnails are attached to their parent asset, not committed separately
835
- if (result.file._isThumbnail) continue;
908
+ if (result.file._isThumbnail || result.file._isDomScene) continue;
836
909
 
837
910
  const groupKey = `${result.file.group.scenarioKey}::${result.file.group.variationSlug}`;
838
911
  if (!groupMap.has(groupKey)) {
@@ -854,6 +927,10 @@ async function publishWithTransactionalFlow(
854
927
  diffStatus: result.file.diffData?.diffStatus ?? null,
855
928
  // Attach thumbnail for video assets
856
929
  thumbnailS3Path: thumbnailS3Paths.get(result.file.visualKey) ?? null,
930
+ // Attach DOM scene sidecar (MHTML) — the source of truth for
931
+ // marketing-team variations.
932
+ domSceneS3Path: domSceneS3Paths.get(result.file.visualKey) ?? null,
933
+ domSceneSize: domSceneSizes.get(result.file.visualKey) ?? null,
857
934
  });
858
935
  }
859
936
 
@@ -942,7 +1019,7 @@ async function publishWithTransactionalFlow(
942
1019
  }
943
1020
  }
944
1021
 
945
- return { successCount, failCount, skippedCount, viewUrl };
1022
+ return { successCount, failCount, skippedCount, viewUrl, failedUploadKeys };
946
1023
  }
947
1024
 
948
1025
  /**
@@ -1217,14 +1294,28 @@ function getGitInfo() {
1217
1294
  try {
1218
1295
  const commitHash = execSync("git rev-parse HEAD", {
1219
1296
  encoding: "utf-8",
1297
+ stdio: ["ignore", "pipe", "ignore"],
1220
1298
  }).trim();
1221
1299
  const commitMessage = execSync("git log -1 --pretty=%B", {
1222
1300
  encoding: "utf-8",
1301
+ stdio: ["ignore", "pipe", "ignore"],
1223
1302
  }).trim();
1224
- return { commitHash, commitMessage };
1303
+ return { commitHash, commitMessage, hasCommit: !!commitHash };
1225
1304
  } catch (error) {
1226
- console.warn(chalk.yellow(" ⚠ Could not read git information"));
1227
- return { commitHash: "", commitMessage: "" };
1305
+ // No git HEAD — either not a git repo or a brand-new repo with no commits.
1306
+ // Proceed WITHOUT git metadata rather than sending empty values that the
1307
+ // platform rejects with an opaque 400 "Batch request failed".
1308
+ console.log(
1309
+ chalk.yellow(
1310
+ " ⚠ No git commit found — publishing without commit metadata.",
1311
+ ),
1312
+ );
1313
+ console.log(
1314
+ chalk.gray(
1315
+ " Tip: run `git commit` first to attach commit info to this publish.",
1316
+ ),
1317
+ );
1318
+ return { commitHash: "", commitMessage: "", hasCommit: false };
1228
1319
  }
1229
1320
  }
1230
1321
 
@@ -1359,9 +1450,23 @@ async function publishCommand(options = {}) {
1359
1450
  };
1360
1451
 
1361
1452
  if (!releaseDoctor.ok) {
1362
- console.log(chalk.red(" ✖ Release doctor failed. Fix the reported issues before publishing."));
1453
+ console.log(chalk.red(" ✖ Release doctor failed. Fix the issues below before publishing:\n"));
1454
+ const blockingIssues = releaseDoctor.summary?.blockingIssues || [];
1455
+ if (blockingIssues.length > 0) {
1456
+ for (const issue of blockingIssues) {
1457
+ const scope = issue.scope ? `${issue.scope}: ` : "";
1458
+ console.log(chalk.red(` ✖ ${scope}${issue.message}`));
1459
+ }
1460
+ } else {
1461
+ console.log(chalk.red(" ✖ Release gate checks failed (see report for details)."));
1462
+ }
1463
+ const advisories = releaseDoctor.summary?.advisories || [];
1464
+ for (const advisory of advisories) {
1465
+ const scope = advisory.scope ? `${advisory.scope}: ` : "";
1466
+ console.log(chalk.yellow(` ⚠ ${scope}${advisory.message}`));
1467
+ }
1363
1468
  if (releaseDoctor.reportPath) {
1364
- console.log(chalk.gray(` Report: ${releaseDoctor.reportPath}`));
1469
+ console.log(chalk.gray(`\n Full report: ${releaseDoctor.reportPath}`));
1365
1470
  }
1366
1471
  if (!noExit) process.exit(1);
1367
1472
  return {
@@ -1482,7 +1587,37 @@ async function publishCommand(options = {}) {
1482
1587
  if (allOutput) {
1483
1588
  screenshotFiles = findAssetFiles(outputBaseDir);
1484
1589
  } else {
1485
- const latestRunManifest = getLatestSuccessfulRunManifest();
1590
+ const usable = getLatestUsableRunManifest();
1591
+ if (!usable) {
1592
+ throw new Error(
1593
+ "No run manifest with successful scenarios found. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
1594
+ );
1595
+ }
1596
+ const { manifest: latestRunManifest, isFallback, isPartialSuccess } = usable;
1597
+
1598
+ if (isFallback) {
1599
+ const age = latestRunManifest.generatedAt
1600
+ ? `generated ${latestRunManifest.generatedAt}`
1601
+ : "unknown age";
1602
+ console.log(
1603
+ chalk.yellow(
1604
+ `\n WARNING: Latest run has no successful scenarios. Falling back to older manifest (${age}).` +
1605
+ `\n The published screenshots may be STALE. Run \`reshot run\` to capture fresh screenshots.\n`,
1606
+ ),
1607
+ );
1608
+ } else if (isPartialSuccess) {
1609
+ const allScenarios = latestRunManifest.scenarios || [];
1610
+ const failed = allScenarios.filter((s) => s.success === false);
1611
+ const succeeded = allScenarios.filter((s) => s.success !== false);
1612
+ console.log(
1613
+ chalk.yellow(
1614
+ `\n WARNING: Latest run had partial failures (${succeeded.length} passed, ${failed.length} failed).` +
1615
+ `\n Publishing only the ${succeeded.length} successful scenario(s).` +
1616
+ `\n Failed: ${failed.map((s) => s.key).join(", ")}\n`,
1617
+ ),
1618
+ );
1619
+ }
1620
+
1486
1621
  const manifestScoped = resolveManifestScopedScreenshotFiles(
1487
1622
  outputBaseDir,
1488
1623
  latestRunManifest,
@@ -1627,11 +1762,38 @@ async function publishCommand(options = {}) {
1627
1762
  }
1628
1763
  if (failCount > 0) {
1629
1764
  console.log(chalk.red(` ✖ Failed: ${failCount}`));
1765
+ const failedKeys = viewUrl ? [] : (publishResult.failedUploadKeys || []);
1766
+ if (failedKeys.length > 0) {
1767
+ for (const key of failedKeys.slice(0, 5)) {
1768
+ console.log(chalk.red(` - ${key}`));
1769
+ }
1770
+ if (failedKeys.length > 5) {
1771
+ console.log(chalk.red(` ... and ${failedKeys.length - 5} more`));
1772
+ }
1773
+ }
1630
1774
  }
1631
1775
  if (viewUrl) {
1632
1776
  console.log(chalk.cyan(`\n 🔗 View in platform: ${viewUrl}`));
1633
1777
  }
1634
1778
 
1779
+ if (!autoApprove && successCount > 0) {
1780
+ console.log(
1781
+ chalk.cyan(
1782
+ `\n ℹ ${successCount} visual${successCount === 1 ? "" : "s"} awaiting review (PENDING)`,
1783
+ ),
1784
+ );
1785
+ console.log(
1786
+ chalk.gray(
1787
+ " To skip review for first-time captures, re-run with `reshot publish --auto-approve`",
1788
+ ),
1789
+ );
1790
+ console.log(
1791
+ chalk.gray(
1792
+ " or approve them in the studio link above.",
1793
+ ),
1794
+ );
1795
+ }
1796
+
1635
1797
  // Helpful guidance about diff percentages
1636
1798
  if (diffManifests && diffManifests.size > 0) {
1637
1799
  console.log(
@@ -255,7 +255,7 @@ async function pullCommand(options = {}) {
255
255
  format = "json",
256
256
  output = null,
257
257
  full = false,
258
- status = "approved",
258
+ status = "all",
259
259
  noExit = false,
260
260
  } = options;
261
261
 
@@ -273,7 +273,10 @@ async function pullCommand(options = {}) {
273
273
  return { success: false, error: "No reshot.config.json found" };
274
274
  }
275
275
 
276
- let projectId = projectConfig._metadata?.projectId || projectConfig.projectId;
276
+ let projectId =
277
+ process.env.RESHOT_PROJECT_ID ||
278
+ projectConfig._metadata?.projectId ||
279
+ projectConfig.projectId;
277
280
  if (!projectId) {
278
281
  // Fall back to reading projectId from .reshot/settings.json (written by `reshot auth`)
279
282
  try {
@@ -315,7 +318,7 @@ async function pullCommand(options = {}) {
315
318
  if (settings.projectId && projectId !== settings.projectId) {
316
319
  console.warn(
317
320
  chalk.yellow(
318
- ` ⚠ Project ID mismatch: config uses ${projectId}, but authenticated project is ${settings.projectId}.`
321
+ ` ⚠ Project ID mismatch: active project is ${projectId}, but authenticated project is ${settings.projectId}.`
319
322
  )
320
323
  );
321
324
  console.warn(
@@ -356,9 +359,11 @@ async function pullCommand(options = {}) {
356
359
  console.log(chalk.gray(` Possible reasons:`));
357
360
  if (status === "approved") {
358
361
  console.log(chalk.gray(` • No visuals approved yet. Try: reshot pull --status all`));
362
+ } else if (status === "pending") {
363
+ console.log(chalk.gray(` • No pending visuals. Try: reshot pull --status all`));
359
364
  }
360
365
  console.log(chalk.gray(` • No visuals published. Run: reshot publish`));
361
- console.log(chalk.gray(` • Wrong project. Config uses: ${projectId}`));
366
+ console.log(chalk.gray(` • Wrong project. Active project: ${projectId}`));
362
367
  console.log(chalk.gray(` • Check: ${settings.platformUrl || "https://reshot.dev"}\n`));
363
368
  } else {
364
369
  console.log(chalk.green(` ✓ Fetched ${assetCount} visuals\n`));
@@ -0,0 +1,166 @@
1
+ // refresh.js — Phase 5 auto-update loop (CI-runnable).
2
+ //
3
+ // `reshot refresh --composition <id>` or `reshot refresh --project <id>`:
4
+ // recapture each composition's source screen, run the calibrated drift check, and
5
+ // either re-publish (data changed, structure-stable) or flag for human review
6
+ // (structural redesign / lost eligibility). Idempotent: a re-run with no source
7
+ // changes is a no-op (0 renders, 0 review items).
8
+
9
+ const fs = require("fs");
10
+ const chalk = require("chalk");
11
+ const { refresh, registerComposition, setScene } = require("../lib/auto-update/refresh");
12
+
13
+ // Read + parse the --scene <path> JSON (the spec.scene render config: camera/motion
14
+ // /timeline/targets/...). Returns undefined when no path was given.
15
+ function readSceneFile(scenePath) {
16
+ if (!scenePath) return undefined;
17
+ let raw;
18
+ try {
19
+ raw = fs.readFileSync(scenePath, "utf8");
20
+ } catch (error) {
21
+ throw new Error(`--scene: cannot read ${scenePath}: ${error.message}`);
22
+ }
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch (error) {
26
+ throw new Error(`--scene: ${scenePath} is not valid JSON: ${error.message}`);
27
+ }
28
+ }
29
+
30
+ function parseViewport(spec) {
31
+ if (!spec) return undefined;
32
+ const m = /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(String(spec).trim());
33
+ if (!m) throw new Error(`Invalid --viewport "${spec}" (expected WxH or WxH@scale, e.g. 1280x900 or 1280x900@2)`);
34
+ return { width: Number(m[1]), height: Number(m[2]), deviceScaleFactor: m[3] ? Number(m[3]) : 2 };
35
+ }
36
+
37
+ // Build the capture-auth config (stored on the spec, re-resolved on every recapture)
38
+ // so the loop can reach authenticated /app screens. --storage-state wins if both set.
39
+ function buildAuth(options) {
40
+ if (options.storageState) return { mode: "storage-state", path: options.storageState };
41
+ if (options.demoAuth) {
42
+ return { mode: "demo-bootstrap", email: typeof options.demoAuth === "string" ? options.demoAuth : "demo@example.com" };
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ const ACTION_LABEL = {
48
+ publish: chalk.green("published"),
49
+ flag: chalk.yellow("flagged"),
50
+ skip: chalk.gray("skipped"),
51
+ error: chalk.red("errored"),
52
+ };
53
+
54
+ function printSummary(result) {
55
+ console.log(chalk.cyan("\nReshot refresh — per-composition result\n"));
56
+ for (const s of result.summaries) {
57
+ const label = ACTION_LABEL[s.action] || s.action;
58
+ const metrics = s.metrics
59
+ ? ` diff=${s.metrics.pixelDiffPct}% ssim=${s.metrics.ssim}`
60
+ : "";
61
+ console.log(
62
+ ` ${label} ${chalk.bold(s.slug)} (${s.compositionId})\n` +
63
+ ` route=${s.route} eligible=${s.eligible} structureStable=${s.structureStable} quality=${s.qualityPass}${metrics}\n` +
64
+ ` ${chalk.gray(s.reason)}`,
65
+ );
66
+ }
67
+ console.log(
68
+ chalk.cyan(
69
+ `\nrendersCreated=${result.rendersCreated} reviewItemsCreated=${result.reviewItemsCreated} ` +
70
+ `published=${result.published} flagged=${result.flagged} skipped=${result.skipped} errors=${result.errors}`,
71
+ ),
72
+ );
73
+ }
74
+
75
+ async function refreshCommand(options = {}) {
76
+ // Attach/update the crisp <Scene>+motion render config on an already-enrolled
77
+ // composition (so the loop renders it animated instead of the video fallback).
78
+ if (options.setScene) {
79
+ const result = await setScene(options.composition, readSceneFile(options.scene));
80
+ if (options.json) console.log(JSON.stringify(result, null, 2));
81
+ else console.log(chalk.green("scene set ") + chalk.bold(result.compositionId) + ` (render=${result.mode})`);
82
+ return result;
83
+ }
84
+
85
+ if (options.register) {
86
+ const result = await registerComposition({
87
+ compositionId: options.composition,
88
+ projectId: options.project || process.env.RESHOT_PROJECT_ID,
89
+ url: options.url,
90
+ viewport: parseViewport(options.viewport),
91
+ composePath: options.compose,
92
+ slug: options.slug,
93
+ name: options.name,
94
+ scene: readSceneFile(options.scene),
95
+ auth: buildAuth(options),
96
+ });
97
+ if (options.json) {
98
+ console.log(JSON.stringify(result, null, 2));
99
+ } else {
100
+ console.log(
101
+ chalk.green("registered ") +
102
+ chalk.bold(result.slug || result.compositionId) +
103
+ ` (route=${result.route}, eligible=${result.eligible}, render=${result.render})`,
104
+ );
105
+ }
106
+ return result;
107
+ }
108
+
109
+ if (!options.composition && !options.project) {
110
+ throw new Error("Pass --composition <id> or --project <id> (or --register to enroll one).");
111
+ }
112
+
113
+ const result = await refresh({
114
+ compositionId: options.composition,
115
+ projectId: options.project,
116
+ });
117
+
118
+ if (options.json) {
119
+ console.log(JSON.stringify(result, null, 2));
120
+ } else {
121
+ printSummary(result);
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function registerRefresh(program) {
127
+ program
128
+ .command("refresh")
129
+ .description("Recapture composition source screens and auto re-publish or flag drift for review")
130
+ .option("--composition <id>", "Refresh a single composition by id")
131
+ .option("--project <id>", "Refresh every composition with a stored spec in this project")
132
+ .option("--register", "Enroll a composition into the loop: capture its source screen as the accepted baseline")
133
+ .option("--set-scene", "Attach/update the crisp <Scene>+motion render config on an enrolled composition (with --composition + --scene)")
134
+ .option("--scene <path>", "[--register|--set-scene] JSON file with the scene render config (camera/motion/timeline/targets); without it the loop renders video")
135
+ .option("--url <url>", "[--register] source screen URL to recapture")
136
+ .option("--viewport <WxH>", "[--register] capture viewport, e.g. 1280x900 or 1280x900@2")
137
+ .option("--compose <path>", "[--register] path to the composition's .compose.tsx for re-rendering on publish")
138
+ .option("--slug <slug>", "[--register] composition slug (for the upload route + public URL)")
139
+ .option("--name <name>", "[--register] composition display name")
140
+ .option("--demo-auth [email]", "[--register] authenticate the capture via the seeded demo bootstrap (default email demo@example.com) — reaches /app screens")
141
+ .option("--storage-state <path>", "[--register] authenticate the capture with a Playwright storageState JSON exported from a real session")
142
+ .option("--json", "Output the structured result as JSON")
143
+ .option("--fail-on-error", "Exit non-zero (2) if any composition errored — for CI gating")
144
+ .action(async (options) => {
145
+ try {
146
+ const result = await refreshCommand(options);
147
+ if (shouldFailOnError(options, result)) {
148
+ console.error(chalk.red(`Refresh completed with ${result.errors} error(s) (--fail-on-error).`));
149
+ process.exit(2);
150
+ }
151
+ } catch (error) {
152
+ console.error(chalk.red("Error:"), error.message);
153
+ process.exit(1);
154
+ }
155
+ });
156
+ }
157
+
158
+ // CI gating: a completed refresh that had per-composition errors (a screen failed
159
+ // to capture/upload) still exits 0 by default (the batch is isolated, fail-safe).
160
+ // With --fail-on-error, CI can treat that as a non-zero (exit 2) — distinct from a
161
+ // hard crash (exit 1). Pure + exported so it is unit-testable without process.exit.
162
+ function shouldFailOnError(options, result) {
163
+ return Boolean(options && options.failOnError && result && result.errors > 0);
164
+ }
165
+
166
+ module.exports = { refreshCommand, registerRefresh, printSummary, shouldFailOnError };
@@ -7,6 +7,16 @@ const fs = require("fs-extra");
7
7
  const path = require("path");
8
8
  const config = require("../lib/config");
9
9
  const { normalizeConfigContract } = require("../lib/target-contract");
10
+ const { detectCI } = require("../lib/ci-detect");
11
+
12
+ /**
13
+ * Whether the current process can prompt the user interactively.
14
+ * False in CI or when stdin is not a TTY (piped / redirected), where an
15
+ * inquirer prompt would throw `ERR_USE_AFTER_CLOSE: readline`.
16
+ */
17
+ function isInteractive() {
18
+ return !!process.stdin.isTTY && !detectCI().isCI;
19
+ }
10
20
 
11
21
  /**
12
22
  * Detect if this is a Git repository and if it's GitHub
@@ -153,7 +163,13 @@ function writeSetupReport({
153
163
  * Main setup wizard
154
164
  */
155
165
  async function setupWizard(options = {}) {
156
- const { offline = false, force = false, noStudio = false } = options;
166
+ const {
167
+ offline = false,
168
+ force = false,
169
+ noStudio = false,
170
+ project: linkedProjectId,
171
+ token: linkedToken,
172
+ } = options;
157
173
 
158
174
  console.log(chalk.cyan.bold("\n🚀 Reshot Setup\n"));
159
175
 
@@ -182,8 +198,35 @@ async function setupWizard(options = {}) {
182
198
  // No existing config
183
199
  }
184
200
 
201
+ let didLinkFromOptions = false;
202
+ if (linkedProjectId && linkedToken && !offline) {
203
+ const authCommand = require("./auth");
204
+ const authResult = await authCommand({
205
+ projectId: linkedProjectId,
206
+ apiKey: linkedToken,
207
+ });
208
+ existingSettings = config.readSettings();
209
+ isAlreadyAuthed = !!(
210
+ existingSettings?.apiKey && existingSettings?.projectId
211
+ );
212
+ didLinkFromOptions = true;
213
+ console.log(
214
+ chalk.green("\n✔ Connected to"),
215
+ chalk.cyan(existingSettings.projectName || existingSettings.projectId),
216
+ );
217
+ if (authResult?.mode) {
218
+ console.log(chalk.gray(` Mode: ${authResult.mode}`));
219
+ }
220
+ } else if (linkedProjectId && !linkedToken && !offline) {
221
+ console.log(
222
+ chalk.gray(
223
+ `Setup will target project ${linkedProjectId}. Complete browser authentication when prompted.`,
224
+ ),
225
+ );
226
+ }
227
+
185
228
  // If already set up and not forcing, show status and offer options
186
- if ((isAlreadyAuthed || existingConfig) && !force) {
229
+ if ((isAlreadyAuthed || existingConfig) && !force && !didLinkFromOptions) {
187
230
  console.log(
188
231
  chalk.yellow("⚠ Reshot is already configured in this project.\n"),
189
232
  );
@@ -512,7 +555,18 @@ async function setupWizard(options = {}) {
512
555
  return;
513
556
  }
514
557
 
515
- // Offer to launch studio
558
+ // Offer to launch studio. In non-interactive environments (CI, piped stdin)
559
+ // an inquirer prompt throws `ERR_USE_AFTER_CLOSE: readline`, so skip the
560
+ // prompt and auto-answer the safe default (do NOT launch Studio).
561
+ if (!isInteractive()) {
562
+ console.log(
563
+ chalk.gray(
564
+ "Non-interactive environment detected — skipping Studio launch. Run `reshot studio` when you want the local UI.\n",
565
+ ),
566
+ );
567
+ return;
568
+ }
569
+
516
570
  const { launchStudio } = await inquirer.prompt([
517
571
  {
518
572
  type: "confirm",
@@ -215,7 +215,7 @@ async function fetchJobsData(apiKey, projectId, limit = 5) {
215
215
  async function fetchDriftsData(apiKey, projectId) {
216
216
  try {
217
217
  const response = await apiClient.getDrifts(apiKey, projectId, {
218
- status: "PENDING",
218
+ status: "pending",
219
219
  });
220
220
  return {
221
221
  drifts: response.drifts || [],
@@ -304,6 +304,26 @@ async function buildStatusReport(options = {}) {
304
304
  report.auth.hasApiKey = Boolean(apiKey);
305
305
  report.auth.hasProjectId = Boolean(projectId);
306
306
 
307
+ // Loudly warn when the linked session (settings.json) and the committed
308
+ // config (reshot.config.json) disagree on the project — settings.json wins,
309
+ // so an edited/stale config projectId would otherwise be silently ignored.
310
+ const configProjectId =
311
+ reshotConfig?.projectId || reshotConfig?._metadata?.projectId || null;
312
+ if (
313
+ projectId &&
314
+ configProjectId &&
315
+ configProjectId !== projectId &&
316
+ !process.env.RESHOT_PROJECT_ID
317
+ ) {
318
+ report.issues.push(
319
+ createIssue(
320
+ "config",
321
+ "warning",
322
+ `Project ID mismatch: using ${projectId} (from .reshot/settings.json) but reshot.config.json declares ${configProjectId}. Run \`reshot setup\` to reconcile.`,
323
+ ),
324
+ );
325
+ }
326
+
307
327
  if (!apiKey) {
308
328
  report.issues.push(
309
329
  createIssue(
@@ -386,7 +406,7 @@ async function displayDrifts(apiKey, projectId, limit = 10) {
386
406
  console.log(chalk.bold("\n🔄 Drift Queue\n"));
387
407
 
388
408
  try {
389
- const response = await apiClient.getDrifts(apiKey, projectId, { status: "PENDING" });
409
+ const response = await apiClient.getDrifts(apiKey, projectId, { status: "pending" });
390
410
  const drifts = response.drifts || [];
391
411
  const stats = response.stats || {};
392
412
  renderDrifts(drifts, stats, projectId, limit);