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

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 (60) 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-target.js +36 -4
  7. package/src/commands/drifts.js +13 -1
  8. package/src/commands/publish.js +137 -12
  9. package/src/commands/pull.js +9 -4
  10. package/src/commands/refresh.js +166 -0
  11. package/src/commands/setup-wizard.js +35 -2
  12. package/src/commands/status.js +22 -2
  13. package/src/commands/variation.js +194 -0
  14. package/src/index.js +187 -9
  15. package/src/lib/api-client.js +61 -35
  16. package/src/lib/auto-update/refresh.js +598 -0
  17. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  18. package/src/lib/auto-update/spec.js +89 -0
  19. package/src/lib/capture-engine.js +73 -0
  20. package/src/lib/capture-script-runner.js +280 -134
  21. package/src/lib/certification.js +23 -1
  22. package/src/lib/compose-context.js +156 -0
  23. package/src/lib/compose-pack.js +42 -0
  24. package/src/lib/compose-runtime.js +34 -0
  25. package/src/lib/compose-upload.js +142 -0
  26. package/src/lib/config.js +2 -2
  27. package/src/lib/dom-capture.js +64 -0
  28. package/src/lib/record-clip.js +83 -3
  29. package/src/lib/record-config.js +0 -4
  30. package/src/lib/resolve-targets.js +60 -0
  31. package/src/lib/run-manifest.js +45 -0
  32. package/src/lib/ui-api-helpers.js +118 -0
  33. package/src/lib/ui-api.js +28 -820
  34. package/src/lib/ui-asset-cleanup.js +62 -0
  35. package/src/lib/ui-output-versions.js +165 -0
  36. package/src/lib/ui-recorder-routes.js +341 -0
  37. package/src/lib/ui-scenario-metadata.js +161 -0
  38. package/vendor/compose/dist/auto-update.cjs +5544 -0
  39. package/vendor/compose/dist/auto-update.mjs +5518 -0
  40. package/vendor/compose/dist/capture.cjs +1450 -0
  41. package/vendor/compose/dist/capture.mjs +1416 -0
  42. package/vendor/compose/dist/eligibility.cjs +5331 -0
  43. package/vendor/compose/dist/eligibility.mjs +5313 -0
  44. package/vendor/compose/dist/index.cjs +2046 -0
  45. package/vendor/compose/dist/index.mjs +1997 -0
  46. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  47. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  48. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  49. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  50. package/vendor/compose/dist/render.cjs +558 -0
  51. package/vendor/compose/dist/render.mjs +515 -0
  52. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  53. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  54. package/vendor/compose/dist/verify.cjs +3880 -0
  55. package/vendor/compose/dist/verify.mjs +3858 -0
  56. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  57. package/web/manager/dist/index.html +1 -1
  58. package/src/commands/ingest.js +0 -458
  59. package/src/commands/setup.js +0 -165
  60. 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
 
@@ -677,6 +680,52 @@ async function publishWithTransactionalFlow(
677
680
  }
678
681
  }
679
682
 
683
+ // ── DOM scene sidecars (MHTML bundles next to each PNG) ─────────────
684
+ // The CLI emits an .mhtml alongside each PNG when scenario.domScene !==
685
+ // false. We upload them as sidecar assets linked to the parent PNG's
686
+ // visual version via _parentVisualKey, so the server can persist the
687
+ // sidecar's s3Path on the same VisualVersion row.
688
+ const domSceneSidecars = [];
689
+ for (const f of allFiles) {
690
+ // Only attach MHTML to PNG screenshots (not videos, not other sidecars).
691
+ if (path.extname(f.path).toLowerCase() !== ".png") continue;
692
+ if (f._isThumbnail || f._isDomScene) continue;
693
+
694
+ const candidate =
695
+ (f.asset && f.asset.domScenePath) ||
696
+ f.path.replace(/\.png$/i, ".mhtml");
697
+ if (!fs.existsSync(candidate)) continue;
698
+
699
+ const sidecarStat = fs.statSync(candidate);
700
+ domSceneSidecars.push({
701
+ group: f.group,
702
+ asset: {
703
+ captureKey: f.asset.captureKey,
704
+ path: candidate,
705
+ filename: path.basename(candidate),
706
+ },
707
+ scenarioConfig: f.scenarioConfig,
708
+ key: f.key,
709
+ visualKey: f.visualKey,
710
+ path: candidate,
711
+ size: sidecarStat.size,
712
+ contentType: "multipart/related",
713
+ hash: null,
714
+ diffData: null,
715
+ thumbnailPath: null,
716
+ _isDomScene: true,
717
+ _parentVisualKey: f.visualKey,
718
+ });
719
+ }
720
+ if (domSceneSidecars.length > 0) {
721
+ console.log(
722
+ chalk.gray(
723
+ ` Attaching ${domSceneSidecars.length} DOM scene sidecar(s)...`,
724
+ ),
725
+ );
726
+ allFiles.push(...domSceneSidecars);
727
+ }
728
+
680
729
  // Generate thumbnails for video files (first frame as PNG)
681
730
  const videoExts = new Set([".mp4", ".webm", ".mov"]);
682
731
  const videoFiles = allFiles.filter((f) =>
@@ -776,7 +825,10 @@ async function publishWithTransactionalFlow(
776
825
 
777
826
  // Step 3: Upload files directly to R2 (parallel with concurrency limit)
778
827
  console.log(chalk.gray(" Uploading files directly to R2..."));
779
- const CONCURRENCY = 5;
828
+ const CONCURRENCY = Math.min(
829
+ Math.max(parseInt(process.env.RESHOT_UPLOAD_CONCURRENCY || "10", 10), 1),
830
+ 20,
831
+ );
780
832
  const uploadQueue = [...allFiles];
781
833
  const uploadResults = [];
782
834
 
@@ -818,21 +870,33 @@ async function publishWithTransactionalFlow(
818
870
 
819
871
  // Build a map of thumbnail s3Paths keyed by parent visualKey
820
872
  const thumbnailS3Paths = new Map();
873
+ // Build a map of DOM-scene MHTML s3Paths keyed by parent visualKey.
874
+ // The server attaches these to the parent VisualVersion as a sidecar.
875
+ const domSceneS3Paths = new Map();
876
+ const domSceneSizes = new Map();
821
877
  for (const result of uploadResults) {
822
- if (result.success && result.file._isThumbnail) {
878
+ if (!result.success) continue;
879
+ if (result.file._isThumbnail) {
823
880
  thumbnailS3Paths.set(result.file._parentVisualKey, result.s3Path);
881
+ } else if (result.file._isDomScene) {
882
+ domSceneS3Paths.set(result.file._parentVisualKey, result.s3Path);
883
+ domSceneSizes.set(result.file._parentVisualKey, result.file.size);
824
884
  }
825
885
  }
826
886
 
827
- // Group successful uploads by scenario/variant (skip thumbnails they're metadata)
887
+ // Group successful uploads by scenario/variant (skip thumbnails + DOM
888
+ // sidecars — they're metadata attached to their parent asset).
889
+ const failedUploadKeys = [];
828
890
  const groupMap = new Map();
829
891
  for (const result of uploadResults) {
830
892
  if (!result.success) {
831
- if (!result.file._isThumbnail) failCount++;
893
+ if (!result.file._isThumbnail && !result.file._isDomScene) {
894
+ failCount++;
895
+ failedUploadKeys.push(result.file.visualKey);
896
+ }
832
897
  continue;
833
898
  }
834
- // Thumbnails are attached to their parent asset, not committed separately
835
- if (result.file._isThumbnail) continue;
899
+ if (result.file._isThumbnail || result.file._isDomScene) continue;
836
900
 
837
901
  const groupKey = `${result.file.group.scenarioKey}::${result.file.group.variationSlug}`;
838
902
  if (!groupMap.has(groupKey)) {
@@ -854,6 +918,10 @@ async function publishWithTransactionalFlow(
854
918
  diffStatus: result.file.diffData?.diffStatus ?? null,
855
919
  // Attach thumbnail for video assets
856
920
  thumbnailS3Path: thumbnailS3Paths.get(result.file.visualKey) ?? null,
921
+ // Attach DOM scene sidecar (MHTML) — the source of truth for
922
+ // marketing-team variations.
923
+ domSceneS3Path: domSceneS3Paths.get(result.file.visualKey) ?? null,
924
+ domSceneSize: domSceneSizes.get(result.file.visualKey) ?? null,
857
925
  });
858
926
  }
859
927
 
@@ -942,7 +1010,7 @@ async function publishWithTransactionalFlow(
942
1010
  }
943
1011
  }
944
1012
 
945
- return { successCount, failCount, skippedCount, viewUrl };
1013
+ return { successCount, failCount, skippedCount, viewUrl, failedUploadKeys };
946
1014
  }
947
1015
 
948
1016
  /**
@@ -1482,7 +1550,37 @@ async function publishCommand(options = {}) {
1482
1550
  if (allOutput) {
1483
1551
  screenshotFiles = findAssetFiles(outputBaseDir);
1484
1552
  } else {
1485
- const latestRunManifest = getLatestSuccessfulRunManifest();
1553
+ const usable = getLatestUsableRunManifest();
1554
+ if (!usable) {
1555
+ throw new Error(
1556
+ "No run manifest with successful scenarios found. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
1557
+ );
1558
+ }
1559
+ const { manifest: latestRunManifest, isFallback, isPartialSuccess } = usable;
1560
+
1561
+ if (isFallback) {
1562
+ const age = latestRunManifest.generatedAt
1563
+ ? `generated ${latestRunManifest.generatedAt}`
1564
+ : "unknown age";
1565
+ console.log(
1566
+ chalk.yellow(
1567
+ `\n WARNING: Latest run has no successful scenarios. Falling back to older manifest (${age}).` +
1568
+ `\n The published screenshots may be STALE. Run \`reshot run\` to capture fresh screenshots.\n`,
1569
+ ),
1570
+ );
1571
+ } else if (isPartialSuccess) {
1572
+ const allScenarios = latestRunManifest.scenarios || [];
1573
+ const failed = allScenarios.filter((s) => s.success === false);
1574
+ const succeeded = allScenarios.filter((s) => s.success !== false);
1575
+ console.log(
1576
+ chalk.yellow(
1577
+ `\n WARNING: Latest run had partial failures (${succeeded.length} passed, ${failed.length} failed).` +
1578
+ `\n Publishing only the ${succeeded.length} successful scenario(s).` +
1579
+ `\n Failed: ${failed.map((s) => s.key).join(", ")}\n`,
1580
+ ),
1581
+ );
1582
+ }
1583
+
1486
1584
  const manifestScoped = resolveManifestScopedScreenshotFiles(
1487
1585
  outputBaseDir,
1488
1586
  latestRunManifest,
@@ -1627,11 +1725,38 @@ async function publishCommand(options = {}) {
1627
1725
  }
1628
1726
  if (failCount > 0) {
1629
1727
  console.log(chalk.red(` ✖ Failed: ${failCount}`));
1728
+ const failedKeys = viewUrl ? [] : (publishResult.failedUploadKeys || []);
1729
+ if (failedKeys.length > 0) {
1730
+ for (const key of failedKeys.slice(0, 5)) {
1731
+ console.log(chalk.red(` - ${key}`));
1732
+ }
1733
+ if (failedKeys.length > 5) {
1734
+ console.log(chalk.red(` ... and ${failedKeys.length - 5} more`));
1735
+ }
1736
+ }
1630
1737
  }
1631
1738
  if (viewUrl) {
1632
1739
  console.log(chalk.cyan(`\n 🔗 View in platform: ${viewUrl}`));
1633
1740
  }
1634
1741
 
1742
+ if (!autoApprove && successCount > 0) {
1743
+ console.log(
1744
+ chalk.cyan(
1745
+ `\n ℹ ${successCount} visual${successCount === 1 ? "" : "s"} awaiting review (PENDING)`,
1746
+ ),
1747
+ );
1748
+ console.log(
1749
+ chalk.gray(
1750
+ " To skip review for first-time captures, re-run with `reshot publish --auto-approve`",
1751
+ ),
1752
+ );
1753
+ console.log(
1754
+ chalk.gray(
1755
+ " or approve them in the studio link above.",
1756
+ ),
1757
+ );
1758
+ }
1759
+
1635
1760
  // Helpful guidance about diff percentages
1636
1761
  if (diffManifests && diffManifests.size > 0) {
1637
1762
  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 };
@@ -153,7 +153,13 @@ function writeSetupReport({
153
153
  * Main setup wizard
154
154
  */
155
155
  async function setupWizard(options = {}) {
156
- const { offline = false, force = false, noStudio = false } = options;
156
+ const {
157
+ offline = false,
158
+ force = false,
159
+ noStudio = false,
160
+ project: linkedProjectId,
161
+ token: linkedToken,
162
+ } = options;
157
163
 
158
164
  console.log(chalk.cyan.bold("\n🚀 Reshot Setup\n"));
159
165
 
@@ -182,8 +188,35 @@ async function setupWizard(options = {}) {
182
188
  // No existing config
183
189
  }
184
190
 
191
+ let didLinkFromOptions = false;
192
+ if (linkedProjectId && linkedToken && !offline) {
193
+ const authCommand = require("./auth");
194
+ const authResult = await authCommand({
195
+ projectId: linkedProjectId,
196
+ apiKey: linkedToken,
197
+ });
198
+ existingSettings = config.readSettings();
199
+ isAlreadyAuthed = !!(
200
+ existingSettings?.apiKey && existingSettings?.projectId
201
+ );
202
+ didLinkFromOptions = true;
203
+ console.log(
204
+ chalk.green("\n✔ Connected to"),
205
+ chalk.cyan(existingSettings.projectName || existingSettings.projectId),
206
+ );
207
+ if (authResult?.mode) {
208
+ console.log(chalk.gray(` Mode: ${authResult.mode}`));
209
+ }
210
+ } else if (linkedProjectId && !linkedToken && !offline) {
211
+ console.log(
212
+ chalk.gray(
213
+ `Setup will target project ${linkedProjectId}. Complete browser authentication when prompted.`,
214
+ ),
215
+ );
216
+ }
217
+
185
218
  // If already set up and not forcing, show status and offer options
186
- if ((isAlreadyAuthed || existingConfig) && !force) {
219
+ if ((isAlreadyAuthed || existingConfig) && !force && !didLinkFromOptions) {
187
220
  console.log(
188
221
  chalk.yellow("⚠ Reshot is already configured in this project.\n"),
189
222
  );
@@ -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);