@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.21

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +138 -47
  3. package/package.json +27 -16
  4. package/src/commands/auth.js +159 -30
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/certify.js +62 -0
  7. package/src/commands/compose.js +220 -0
  8. package/src/commands/doctor-release.js +74 -0
  9. package/src/commands/doctor-target.js +108 -0
  10. package/src/commands/drifts.js +16 -69
  11. package/src/commands/import-tests.js +13 -13
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +484 -257
  14. package/src/commands/pull.js +302 -35
  15. package/src/commands/refresh.js +166 -0
  16. package/src/commands/run.js +292 -12
  17. package/src/commands/setup-wizard.js +348 -496
  18. package/src/commands/status.js +334 -126
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/variation.js +194 -0
  22. package/src/commands/verify-publish.js +46 -0
  23. package/src/index.js +383 -118
  24. package/src/lib/api-client.js +172 -60
  25. package/src/lib/auto-update/refresh.js +598 -0
  26. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  27. package/src/lib/auto-update/spec.js +89 -0
  28. package/src/lib/capture-engine.js +179 -9
  29. package/src/lib/capture-script-runner.js +639 -214
  30. package/src/lib/certification.js +887 -0
  31. package/src/lib/compose-context.js +156 -0
  32. package/src/lib/compose-pack.js +42 -0
  33. package/src/lib/compose-runtime.js +34 -0
  34. package/src/lib/compose-upload.js +142 -0
  35. package/src/lib/config.js +186 -81
  36. package/src/lib/dom-capture.js +64 -0
  37. package/src/lib/ensure-browser.js +147 -0
  38. package/src/lib/output-path-template.js +3 -3
  39. package/src/lib/record-cdp.js +288 -16
  40. package/src/lib/record-clip.js +83 -3
  41. package/src/lib/record-config.js +1 -5
  42. package/src/lib/release-doctor.js +321 -0
  43. package/src/lib/resolve-targets.js +60 -0
  44. package/src/lib/run-manifest.js +148 -0
  45. package/src/lib/standalone-mode.js +1 -1
  46. package/src/lib/storage-providers.js +5 -5
  47. package/src/lib/style-engine.js +5 -5
  48. package/src/lib/target-contract.js +292 -0
  49. package/src/lib/ui-api-helpers.js +118 -0
  50. package/src/lib/ui-api.js +31 -824
  51. package/src/lib/ui-asset-cleanup.js +62 -0
  52. package/src/lib/ui-output-versions.js +165 -0
  53. package/src/lib/ui-recorder-routes.js +341 -0
  54. package/src/lib/ui-scenario-metadata.js +161 -0
  55. package/vendor/compose/dist/auto-update.cjs +5544 -0
  56. package/vendor/compose/dist/auto-update.mjs +5518 -0
  57. package/vendor/compose/dist/capture.cjs +1450 -0
  58. package/vendor/compose/dist/capture.mjs +1416 -0
  59. package/vendor/compose/dist/eligibility.cjs +5331 -0
  60. package/vendor/compose/dist/eligibility.mjs +5313 -0
  61. package/vendor/compose/dist/index.cjs +2046 -0
  62. package/vendor/compose/dist/index.mjs +1997 -0
  63. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  64. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  65. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  66. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  67. package/vendor/compose/dist/render.cjs +558 -0
  68. package/vendor/compose/dist/render.mjs +515 -0
  69. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  70. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  71. package/vendor/compose/dist/verify.cjs +3880 -0
  72. package/vendor/compose/dist/verify.mjs +3858 -0
  73. package/web/manager/dist/assets/index-D0S2otug.js +507 -0
  74. package/web/manager/dist/index.html +1 -1
  75. package/src/commands/ci-run.js +0 -123
  76. package/src/commands/ci-setup.js +0 -288
  77. package/src/commands/ingest.js +0 -458
  78. package/src/commands/setup.js +0 -137
  79. package/src/commands/validate-docs.js +0 -529
  80. package/src/lib/playwright-runner.js +0 -252
  81. package/web/manager/dist/assets/index--ZgioErz.js +0 -507
@@ -15,6 +15,10 @@ const {
15
15
  getStorageMode,
16
16
  isPlatformAvailable,
17
17
  } = require("../lib/storage-providers");
18
+ const {
19
+ getLatestSuccessfulRunManifest,
20
+ getLatestUsableRunManifest,
21
+ } = require("../lib/run-manifest");
18
22
  const pkg = require("../../package.json");
19
23
 
20
24
  // Check if transactional flow should be used (R2 configured on server)
@@ -52,20 +56,44 @@ function loadDiffManifests(outputBaseDir) {
52
56
  const latestVersion = versions.sort().reverse()[0];
53
57
  if (!latestVersion) continue;
54
58
 
55
- const manifestPath = path.join(
56
- scenarioDir,
57
- latestVersion,
58
- "diff-manifest.json",
59
- );
59
+ const versionDir = path.join(scenarioDir, latestVersion);
60
+
61
+ // `run` writes diff-manifest.json into the ACTUAL output dir, which
62
+ // includes the variant subdir (e.g. <version>/default/diff-manifest.json),
63
+ // not the version dir itself. Read the version-level manifest if present
64
+ // AND every variant subdir's manifest, merging their `assets` so the diff
65
+ // the CLI computed actually reaches publish (otherwise the platform stores
66
+ // diffPercentage: null and Activity shows "0 changed" — audit run-13 H1).
67
+ const candidatePaths = [path.join(versionDir, "diff-manifest.json")];
68
+ try {
69
+ for (const entry of fs.readdirSync(versionDir)) {
70
+ const entryPath = path.join(versionDir, entry);
71
+ if (fs.statSync(entryPath).isDirectory()) {
72
+ candidatePaths.push(path.join(entryPath, "diff-manifest.json"));
73
+ }
74
+ }
75
+ } catch (e) {
76
+ // version dir unreadable — fall back to the version-level path only
77
+ }
60
78
 
61
- if (fs.existsSync(manifestPath)) {
79
+ const merged = { assets: {} };
80
+ let foundAny = false;
81
+ for (const manifestPath of candidatePaths) {
82
+ if (!fs.existsSync(manifestPath)) continue;
62
83
  try {
63
84
  const manifest = fs.readJSONSync(manifestPath);
64
- manifests.set(`${scenarioKey}/${latestVersion}`, manifest);
85
+ foundAny = true;
86
+ Object.assign(merged, manifest, {
87
+ assets: { ...merged.assets, ...(manifest.assets || {}) },
88
+ });
65
89
  } catch (e) {
66
90
  // Skip malformed manifests
67
91
  }
68
92
  }
93
+
94
+ if (foundAny) {
95
+ manifests.set(`${scenarioKey}/${latestVersion}`, merged);
96
+ }
69
97
  }
70
98
  } catch (e) {
71
99
  // Return empty if directory structure is unexpected
@@ -390,13 +418,13 @@ function resolveProjectContext({ settings, docSyncConfig, storageMode }) {
390
418
  if (!apiKey) {
391
419
  throw new Error(
392
420
  "No API key found. Set RESHOT_API_KEY in your environment or run `reshot auth` locally to create .reshot/settings.json.\n" +
393
- "Alternatively, configure BYOS (Bring Your Own Storage) in docsync.config.json to publish without authentication.",
421
+ "Alternatively, configure BYOS (Bring Your Own Storage) in reshot.config.json to publish without authentication.",
394
422
  );
395
423
  }
396
424
 
397
425
  if (!projectId) {
398
426
  throw new Error(
399
- "No project ID found. Set RESHOT_PROJECT_ID in your environment or ensure docsync.config.json contains _metadata.projectId.",
427
+ "No project ID found. Set RESHOT_PROJECT_ID in your environment or ensure reshot.config.json contains _metadata.projectId.",
400
428
  );
401
429
  }
402
430
 
@@ -487,6 +515,48 @@ function groupAssetsByScenario(assetFiles, outputBaseDir) {
487
515
  return Array.from(groups.values());
488
516
  }
489
517
 
518
+ function collectAssetFilesFromDirectories(directories) {
519
+ const deduped = new Set();
520
+ const files = [];
521
+
522
+ for (const directory of directories) {
523
+ if (!directory || !fs.existsSync(directory)) continue;
524
+ for (const filePath of findAssetFiles(directory)) {
525
+ if (deduped.has(filePath)) continue;
526
+ deduped.add(filePath);
527
+ files.push(filePath);
528
+ }
529
+ }
530
+
531
+ return files;
532
+ }
533
+
534
+ function resolveManifestScopedScreenshotFiles(outputBaseDir, latestRunManifest) {
535
+ if (!latestRunManifest) {
536
+ throw new Error(
537
+ "No run manifest is available. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
538
+ );
539
+ }
540
+
541
+ const scenarioDirs = (latestRunManifest.scenarios || [])
542
+ .filter((scenario) => scenario.success !== false)
543
+ .map((scenario) => scenario.outputDir)
544
+ .filter(Boolean);
545
+
546
+ if (scenarioDirs.length === 0) {
547
+ throw new Error(
548
+ "The run manifest does not contain any successful scenario output directories.",
549
+ );
550
+ }
551
+
552
+ const screenshotFiles = collectAssetFilesFromDirectories(scenarioDirs);
553
+ return {
554
+ screenshotFiles,
555
+ mode: "latest-run",
556
+ scenarioCount: scenarioDirs.length,
557
+ };
558
+ }
559
+
490
560
  function buildContextForVariation(scenario, variationSlug) {
491
561
  const safeVariation = variationSlug || "default";
492
562
  if (!scenario || !scenario.contexts) {
@@ -539,17 +609,25 @@ function buildScenarioDefinition(scenario) {
539
609
  function buildPublishMetadata({
540
610
  projectId,
541
611
  publishSessionId,
612
+ tag,
542
613
  scenarioKey,
543
614
  scenarioConfig,
544
615
  variationSlug,
545
616
  contextData,
546
617
  gitInfo,
618
+ autoApprove = false,
547
619
  }) {
548
620
  const scenarioDefinition = buildScenarioDefinition(scenarioConfig);
549
621
 
622
+ // Only attach git metadata when a real commit hash is available. Sending
623
+ // empty git values when the repo has no HEAD causes the platform to reject
624
+ // the batch with an opaque 400.
625
+ const hasGit = !!(gitInfo && gitInfo.commitHash);
626
+
550
627
  return {
551
628
  projectId,
552
629
  publishSessionId, // Unique ID for this CLI publish run
630
+ tag: tag || undefined,
553
631
  scenarioName: scenarioConfig?.name || scenarioKey,
554
632
  scenario: scenarioDefinition,
555
633
  context: {
@@ -557,10 +635,17 @@ function buildPublishMetadata({
557
635
  data: contextData,
558
636
  },
559
637
  autoCreateVisuals: true,
560
- git: {
561
- commitHash: gitInfo.commitHash,
562
- commitMessage: gitInfo.commitMessage,
638
+ publish: {
639
+ autoApprove,
563
640
  },
641
+ ...(hasGit
642
+ ? {
643
+ git: {
644
+ commitHash: gitInfo.commitHash,
645
+ commitMessage: gitInfo.commitMessage,
646
+ },
647
+ }
648
+ : {}),
564
649
  cli: {
565
650
  version: pkg.version,
566
651
  captureTimestamp: new Date().toISOString(),
@@ -568,6 +653,24 @@ function buildPublishMetadata({
568
653
  };
569
654
  }
570
655
 
656
+ /**
657
+ * Read pixel dimensions from a captured image so the platform can store and
658
+ * expose them (used by `reshot pull` for CLS-safe `<img width height>`
659
+ * embedding). Returns nulls for non-images or on any error — never throws.
660
+ */
661
+ async function getImageDimensions(filePath, contentType) {
662
+ if (!filePath || !(contentType || "").startsWith("image/")) {
663
+ return { width: null, height: null };
664
+ }
665
+ try {
666
+ const sharp = require("sharp");
667
+ const meta = await sharp(filePath).metadata();
668
+ return { width: meta.width ?? null, height: meta.height ?? null };
669
+ } catch {
670
+ return { width: null, height: null };
671
+ }
672
+ }
673
+
571
674
  /**
572
675
  * Publish using transactional flow (direct R2 upload with presigned URLs)
573
676
  */
@@ -578,6 +681,7 @@ async function publishWithTransactionalFlow(
578
681
  docSyncConfig,
579
682
  gitInfo,
580
683
  diffManifests = null,
684
+ { autoApprove = false } = {},
581
685
  ) {
582
686
  console.log(
583
687
  chalk.cyan(" 🚀 Using transactional upload (direct to R2)...\n"),
@@ -586,6 +690,7 @@ async function publishWithTransactionalFlow(
586
690
  let successCount = 0;
587
691
  let failCount = 0;
588
692
  let skippedCount = 0;
693
+ let reviewPendingCount = 0;
589
694
  let viewUrl = null;
590
695
 
591
696
  // Flatten all assets with metadata
@@ -627,6 +732,52 @@ async function publishWithTransactionalFlow(
627
732
  }
628
733
  }
629
734
 
735
+ // ── DOM scene sidecars (MHTML bundles next to each PNG) ─────────────
736
+ // The CLI emits an .mhtml alongside each PNG when scenario.domScene !==
737
+ // false. We upload them as sidecar assets linked to the parent PNG's
738
+ // visual version via _parentVisualKey, so the server can persist the
739
+ // sidecar's s3Path on the same VisualVersion row.
740
+ const domSceneSidecars = [];
741
+ for (const f of allFiles) {
742
+ // Only attach MHTML to PNG screenshots (not videos, not other sidecars).
743
+ if (path.extname(f.path).toLowerCase() !== ".png") continue;
744
+ if (f._isThumbnail || f._isDomScene) continue;
745
+
746
+ const candidate =
747
+ (f.asset && f.asset.domScenePath) ||
748
+ f.path.replace(/\.png$/i, ".mhtml");
749
+ if (!fs.existsSync(candidate)) continue;
750
+
751
+ const sidecarStat = fs.statSync(candidate);
752
+ domSceneSidecars.push({
753
+ group: f.group,
754
+ asset: {
755
+ captureKey: f.asset.captureKey,
756
+ path: candidate,
757
+ filename: path.basename(candidate),
758
+ },
759
+ scenarioConfig: f.scenarioConfig,
760
+ key: f.key,
761
+ visualKey: f.visualKey,
762
+ path: candidate,
763
+ size: sidecarStat.size,
764
+ contentType: "multipart/related",
765
+ hash: null,
766
+ diffData: null,
767
+ thumbnailPath: null,
768
+ _isDomScene: true,
769
+ _parentVisualKey: f.visualKey,
770
+ });
771
+ }
772
+ if (domSceneSidecars.length > 0) {
773
+ console.log(
774
+ chalk.gray(
775
+ ` Attaching ${domSceneSidecars.length} DOM scene sidecar(s)...`,
776
+ ),
777
+ );
778
+ allFiles.push(...domSceneSidecars);
779
+ }
780
+
630
781
  // Generate thumbnails for video files (first frame as PNG)
631
782
  const videoExts = new Set([".mp4", ".webm", ".mov"]);
632
783
  const videoFiles = allFiles.filter((f) =>
@@ -726,7 +877,10 @@ async function publishWithTransactionalFlow(
726
877
 
727
878
  // Step 3: Upload files directly to R2 (parallel with concurrency limit)
728
879
  console.log(chalk.gray(" Uploading files directly to R2..."));
729
- const CONCURRENCY = 5;
880
+ const CONCURRENCY = Math.min(
881
+ Math.max(parseInt(process.env.RESHOT_UPLOAD_CONCURRENCY || "10", 10), 1),
882
+ 20,
883
+ );
730
884
  const uploadQueue = [...allFiles];
731
885
  const uploadResults = [];
732
886
 
@@ -749,7 +903,16 @@ async function publishWithTransactionalFlow(
749
903
  contentType: file.contentType,
750
904
  });
751
905
 
752
- console.log(chalk.green(` ✔ Uploaded ${file.visualKey}`));
906
+ // Label the artifact type so PNG + MHTML-sidecar lines for the same
907
+ // visual don't read as a duplicate-upload bug (audit run-10 F3).
908
+ const artifactLabel = file._isThumbnail
909
+ ? "thumbnail"
910
+ : file._isDomScene
911
+ ? "dom scene .mhtml"
912
+ : (path.extname(file.path || "").replace(/^\./, "") || "asset");
913
+ console.log(
914
+ chalk.green(` ✔ Uploaded ${file.visualKey} (${artifactLabel})`),
915
+ );
753
916
  return { success: true, file, s3Path: urlInfo.path };
754
917
  } catch (err) {
755
918
  console.log(
@@ -768,21 +931,33 @@ async function publishWithTransactionalFlow(
768
931
 
769
932
  // Build a map of thumbnail s3Paths keyed by parent visualKey
770
933
  const thumbnailS3Paths = new Map();
934
+ // Build a map of DOM-scene MHTML s3Paths keyed by parent visualKey.
935
+ // The server attaches these to the parent VisualVersion as a sidecar.
936
+ const domSceneS3Paths = new Map();
937
+ const domSceneSizes = new Map();
771
938
  for (const result of uploadResults) {
772
- if (result.success && result.file._isThumbnail) {
939
+ if (!result.success) continue;
940
+ if (result.file._isThumbnail) {
773
941
  thumbnailS3Paths.set(result.file._parentVisualKey, result.s3Path);
942
+ } else if (result.file._isDomScene) {
943
+ domSceneS3Paths.set(result.file._parentVisualKey, result.s3Path);
944
+ domSceneSizes.set(result.file._parentVisualKey, result.file.size);
774
945
  }
775
946
  }
776
947
 
777
- // Group successful uploads by scenario/variant (skip thumbnails they're metadata)
948
+ // Group successful uploads by scenario/variant (skip thumbnails + DOM
949
+ // sidecars — they're metadata attached to their parent asset).
950
+ const failedUploadKeys = [];
778
951
  const groupMap = new Map();
779
952
  for (const result of uploadResults) {
780
953
  if (!result.success) {
781
- if (!result.file._isThumbnail) failCount++;
954
+ if (!result.file._isThumbnail && !result.file._isDomScene) {
955
+ failCount++;
956
+ failedUploadKeys.push(result.file.visualKey);
957
+ }
782
958
  continue;
783
959
  }
784
- // Thumbnails are attached to their parent asset, not committed separately
785
- if (result.file._isThumbnail) continue;
960
+ if (result.file._isThumbnail || result.file._isDomScene) continue;
786
961
 
787
962
  const groupKey = `${result.file.group.scenarioKey}::${result.file.group.variationSlug}`;
788
963
  if (!groupMap.has(groupKey)) {
@@ -792,22 +967,36 @@ async function publishWithTransactionalFlow(
792
967
  assets: [],
793
968
  });
794
969
  }
970
+ const dimensions = await getImageDimensions(
971
+ result.file.path,
972
+ result.file.contentType,
973
+ );
795
974
  groupMap.get(groupKey).assets.push({
796
975
  key: result.file.key,
797
976
  s3Path: result.s3Path,
798
977
  hash: result.file.hash,
799
978
  visualKey: result.file.visualKey,
800
979
  size: result.file.size,
980
+ width: dimensions.width,
981
+ height: dimensions.height,
801
982
  contentType: result.file.contentType,
802
983
  // Include diff data from CLI analysis
803
984
  diffPercentage: result.file.diffData?.diffPercentage ?? null,
804
985
  diffStatus: result.file.diffData?.diffStatus ?? null,
805
986
  // Attach thumbnail for video assets
806
987
  thumbnailS3Path: thumbnailS3Paths.get(result.file.visualKey) ?? null,
988
+ // Attach DOM scene sidecar (MHTML) — the source of truth for
989
+ // marketing-team variations.
990
+ domSceneS3Path: domSceneS3Paths.get(result.file.visualKey) ?? null,
991
+ domSceneSize: domSceneSizes.get(result.file.visualKey) ?? null,
807
992
  });
808
993
  }
809
994
 
810
- // Commit each group
995
+ // Build all commits for batch request
996
+ // Vercel serverless functions have ~60s timeout; keep batches small enough to complete
997
+ const MAX_BATCH_SIZE = 25;
998
+ const commits = [];
999
+
811
1000
  for (const { group, scenarioConfig, assets } of groupMap.values()) {
812
1001
  const contextObj = buildContextForVariation(
813
1002
  scenarioConfig,
@@ -816,50 +1005,98 @@ async function publishWithTransactionalFlow(
816
1005
  const metadata = buildPublishMetadata({
817
1006
  projectId,
818
1007
  publishSessionId: gitInfo.publishSessionId,
1008
+ tag: gitInfo.tag,
819
1009
  scenarioKey: group.scenarioKey,
820
1010
  scenarioConfig,
821
1011
  variationSlug: group.variationSlug,
822
1012
  contextData: contextObj,
823
1013
  gitInfo,
1014
+ autoApprove,
824
1015
  });
825
1016
 
826
1017
  if (metadata.cli) {
827
- metadata.cli.features = ["steps", "transactional"];
1018
+ metadata.cli.features = ["steps", "transactional", "batch"];
1019
+ }
1020
+
1021
+ commits.push({ metadata, assets });
1022
+ }
1023
+
1024
+ // Send in batches
1025
+ console.log(
1026
+ chalk.gray(` Committing ${commits.length} scenario(s) to platform...`),
1027
+ );
1028
+
1029
+ const totalBatches = Math.ceil(commits.length / MAX_BATCH_SIZE);
1030
+ for (let i = 0; i < commits.length; i += MAX_BATCH_SIZE) {
1031
+ const chunk = commits.slice(i, i + MAX_BATCH_SIZE);
1032
+ const batchNum = Math.floor(i / MAX_BATCH_SIZE) + 1;
1033
+ if (totalBatches > 1) {
1034
+ console.log(chalk.gray(` Batch ${batchNum}/${totalBatches}...`));
828
1035
  }
829
1036
 
830
1037
  try {
831
- const result = await apiClient.publishTransactional(apiKey, {
832
- metadata,
833
- assets,
1038
+ const rawBatchResult = await apiClient.publishBatch(apiKey, {
1039
+ commits: chunk,
1040
+ autoApprove: autoApprove || false,
834
1041
  });
1042
+ // Unwrap API envelope: response may be { data: { results, ... } } or { results, ... }
1043
+ const batchResult = rawBatchResult.data || rawBatchResult;
835
1044
 
836
- const processedCount = result?.assetsProcessed ?? assets.length;
837
- console.log(
838
- chalk.green(
839
- ` ✔ Committed "${group.scenarioKey}" (${group.variationSlug}): ${processedCount} asset(s)`,
840
- ),
841
- );
842
- successCount += processedCount;
1045
+ // Count only NEWLY-pending (new/changed) captures, so the final summary
1046
+ // doesn't imply a re-publish reset already-live captures (audit F1).
1047
+ reviewPendingCount += batchResult.reviewQueueItems || 0;
1048
+
1049
+ for (const r of batchResult.results || []) {
1050
+ if (r.status === "ok") {
1051
+ const count = r.assetsProcessed || 0;
1052
+ console.log(
1053
+ chalk.green(
1054
+ ` ✔ Committed "${r.scenario}" (${r.context}): ${count} asset(s)`,
1055
+ ),
1056
+ );
1057
+ successCount += count;
843
1058
 
844
- // Handle skipped assets (visual limit)
845
- if (result?.skippedAssets?.length > 0) {
846
- for (const key of result.skippedAssets) {
847
- console.log(chalk.yellow(` ⚠ Skipped "${key}" (plan limit reached)`));
1059
+ if (r.skippedAssets?.length > 0) {
1060
+ for (const key of r.skippedAssets) {
1061
+ console.log(chalk.yellow(` ⚠ Skipped "${key}" (plan limit reached)`));
1062
+ }
1063
+ skippedCount += r.skippedAssets.length;
1064
+ }
1065
+ } else {
1066
+ console.log(
1067
+ chalk.red(
1068
+ ` ✖ "${r.scenario}" (${r.context}): ${r.error || "Unknown error"}`,
1069
+ ),
1070
+ );
1071
+ failCount++;
848
1072
  }
849
- skippedCount += result.skippedAssets.length;
850
1073
  }
851
1074
 
852
- // Capture viewUrl from first successful response
853
- if (!viewUrl && result?.viewUrl) {
854
- viewUrl = result.viewUrl;
1075
+ if (!viewUrl && batchResult.viewUrl) {
1076
+ viewUrl = batchResult.viewUrl;
855
1077
  }
856
1078
  } catch (error) {
857
- console.log(chalk.red(` ✖ Commit failed: ${error.message}`));
858
- failCount += assets.length;
1079
+ // Surface the server's descriptive error body (e.g. a validation
1080
+ // message) instead of just axios's opaque "Request failed with status
1081
+ // code 400" — otherwise a 1-line fix turns into a support ticket.
1082
+ const serverMsg =
1083
+ error.response?.data?.error || error.response?.data?.message;
1084
+ const detail = serverMsg
1085
+ ? `${error.message} — ${serverMsg}`
1086
+ : error.message;
1087
+ console.log(chalk.red(` ✖ Batch request failed: ${detail}`));
1088
+ failCount += chunk.length;
859
1089
  }
860
1090
  }
861
1091
 
862
- return { successCount, failCount, skippedCount, viewUrl };
1092
+ return {
1093
+ successCount,
1094
+ failCount,
1095
+ skippedCount,
1096
+ reviewPendingCount,
1097
+ viewUrl,
1098
+ failedUploadKeys,
1099
+ };
863
1100
  }
864
1101
 
865
1102
  /**
@@ -871,6 +1108,7 @@ async function publishWithLegacyFlow(
871
1108
  groupedAssets,
872
1109
  docSyncConfig,
873
1110
  gitInfo,
1111
+ { autoApprove = false } = {},
874
1112
  ) {
875
1113
  let successCount = 0;
876
1114
  let failCount = 0;
@@ -887,11 +1125,13 @@ async function publishWithLegacyFlow(
887
1125
  const metadata = buildPublishMetadata({
888
1126
  projectId,
889
1127
  publishSessionId: gitInfo.publishSessionId,
1128
+ tag: gitInfo.tag,
890
1129
  scenarioKey: group.scenarioKey,
891
1130
  scenarioConfig,
892
1131
  variationSlug: group.variationSlug,
893
1132
  contextData: contextObj,
894
1133
  gitInfo,
1134
+ autoApprove,
895
1135
  });
896
1136
 
897
1137
  if (metadata.cli) {
@@ -1131,14 +1371,28 @@ function getGitInfo() {
1131
1371
  try {
1132
1372
  const commitHash = execSync("git rev-parse HEAD", {
1133
1373
  encoding: "utf-8",
1374
+ stdio: ["ignore", "pipe", "ignore"],
1134
1375
  }).trim();
1135
1376
  const commitMessage = execSync("git log -1 --pretty=%B", {
1136
1377
  encoding: "utf-8",
1378
+ stdio: ["ignore", "pipe", "ignore"],
1137
1379
  }).trim();
1138
- return { commitHash, commitMessage };
1380
+ return { commitHash, commitMessage, hasCommit: !!commitHash };
1139
1381
  } catch (error) {
1140
- console.warn(chalk.yellow(" ⚠ Could not read git information"));
1141
- return { commitHash: "", commitMessage: "" };
1382
+ // No git HEAD — either not a git repo or a brand-new repo with no commits.
1383
+ // Proceed WITHOUT git metadata rather than sending empty values that the
1384
+ // platform rejects with an opaque 400 "Batch request failed".
1385
+ console.log(
1386
+ chalk.yellow(
1387
+ " ⚠ No git commit found — publishing without commit metadata.",
1388
+ ),
1389
+ );
1390
+ console.log(
1391
+ chalk.gray(
1392
+ " Tip: run `git commit` first to attach commit info to this publish.",
1393
+ ),
1394
+ );
1395
+ return { commitHash: "", commitMessage: "", hasCommit: false };
1142
1396
  }
1143
1397
  }
1144
1398
 
@@ -1167,70 +1421,6 @@ function getRecentCommits(lastCommitHash) {
1167
1421
  }
1168
1422
  }
1169
1423
 
1170
- /**
1171
- * Recursively find all markdown files matching include/exclude patterns
1172
- */
1173
- function findDocFiles(
1174
- docsRoot,
1175
- includePatterns = ["**/*.md", "**/*.mdx"],
1176
- excludePatterns = [],
1177
- ) {
1178
- const files = [];
1179
- const rootPath = path.resolve(process.cwd(), docsRoot);
1180
-
1181
- if (!fs.existsSync(rootPath)) {
1182
- return files;
1183
- }
1184
-
1185
- function walkDir(dir, relativePath = "") {
1186
- const items = fs.readdirSync(dir);
1187
-
1188
- for (const item of items) {
1189
- const fullPath = path.join(dir, item);
1190
- const relativeItemPath = path
1191
- .join(relativePath, item)
1192
- .replace(/\\/g, "/");
1193
- const stat = fs.statSync(fullPath);
1194
-
1195
- // Check exclude patterns
1196
- const shouldExclude = excludePatterns.some((pattern) => {
1197
- const regex = new RegExp(
1198
- pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"),
1199
- );
1200
- return regex.test(relativeItemPath) || regex.test(fullPath);
1201
- });
1202
-
1203
- if (shouldExclude) {
1204
- continue;
1205
- }
1206
-
1207
- if (stat.isDirectory()) {
1208
- walkDir(fullPath, relativeItemPath);
1209
- } else if (stat.isFile()) {
1210
- const ext = path.extname(item).toLowerCase();
1211
- const matchesInclude = includePatterns.some((pattern) => {
1212
- const regex = new RegExp(
1213
- pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"),
1214
- );
1215
- return (
1216
- regex.test(relativeItemPath) || ext === ".md" || ext === ".mdx"
1217
- );
1218
- });
1219
-
1220
- if (matchesInclude && (ext === ".md" || ext === ".mdx")) {
1221
- files.push({
1222
- fullPath,
1223
- relativePath: relativeItemPath,
1224
- });
1225
- }
1226
- }
1227
- }
1228
- }
1229
-
1230
- walkDir(rootPath);
1231
- return files;
1232
- }
1233
-
1234
1424
  /**
1235
1425
  * Parse frontmatter from markdown content
1236
1426
  */
@@ -1268,7 +1458,18 @@ function parseFrontmatter(content) {
1268
1458
  }
1269
1459
 
1270
1460
  async function publishCommand(options = {}) {
1271
- const { tag, message, dryRun, force, video, outputJson } = options;
1461
+ const {
1462
+ tag,
1463
+ message,
1464
+ dryRun,
1465
+ force,
1466
+ video,
1467
+ allOutput = false,
1468
+ outputJson,
1469
+ autoApprove,
1470
+ skipReleaseDoctor = false,
1471
+ noExit = false,
1472
+ } = options;
1272
1473
 
1273
1474
  // Result tracking for --output-json and programmatic callers
1274
1475
  const publishResult = {
@@ -1277,6 +1478,7 @@ async function publishCommand(options = {}) {
1277
1478
  assetsSkipped: 0,
1278
1479
  reviewQueueItems: 0,
1279
1480
  viewUrl: null,
1481
+ releaseDoctor: null,
1280
1482
  tag: tag || null,
1281
1483
  dryRun: !!dryRun,
1282
1484
  timestamp: new Date().toISOString(),
@@ -1294,6 +1496,10 @@ async function publishCommand(options = {}) {
1294
1496
  console.log(chalk.yellow("🔍 DRY RUN MODE - No assets will be uploaded\n"));
1295
1497
  }
1296
1498
 
1499
+ if (autoApprove) {
1500
+ console.log(chalk.cyan(" ✅ Auto-approve enabled: visuals will be approved immediately\n"));
1501
+ }
1502
+
1297
1503
  // Read config + settings (if available)
1298
1504
  const settings = readSettingsSafe();
1299
1505
  let docSyncConfig = null;
@@ -1305,6 +1511,57 @@ async function publishCommand(options = {}) {
1305
1511
  );
1306
1512
  }
1307
1513
 
1514
+ if (skipReleaseDoctor) {
1515
+ publishResult.releaseDoctor = {
1516
+ skipped: true,
1517
+ success: true,
1518
+ };
1519
+ } else if (docSyncConfig) {
1520
+ console.log(chalk.cyan("🧪 Running release doctor before publish...\n"));
1521
+ const { runReleaseDoctor } = require("../lib/release-doctor");
1522
+ const releaseDoctor = await runReleaseDoctor({});
1523
+ publishResult.releaseDoctor = {
1524
+ skipped: false,
1525
+ success: releaseDoctor.ok,
1526
+ reportPath: releaseDoctor.reportPath || null,
1527
+ };
1528
+
1529
+ if (!releaseDoctor.ok) {
1530
+ console.log(chalk.red(" ✖ Release doctor failed. Fix the issues below before publishing:\n"));
1531
+ const blockingIssues = releaseDoctor.summary?.blockingIssues || [];
1532
+ if (blockingIssues.length > 0) {
1533
+ for (const issue of blockingIssues) {
1534
+ const scope = issue.scope ? `${issue.scope}: ` : "";
1535
+ console.log(chalk.red(` ✖ ${scope}${issue.message}`));
1536
+ }
1537
+ } else {
1538
+ console.log(chalk.red(" ✖ Release gate checks failed (see report for details)."));
1539
+ }
1540
+ const advisories = releaseDoctor.summary?.advisories || [];
1541
+ for (const advisory of advisories) {
1542
+ const scope = advisory.scope ? `${advisory.scope}: ` : "";
1543
+ console.log(chalk.yellow(` ⚠ ${scope}${advisory.message}`));
1544
+ }
1545
+ if (releaseDoctor.reportPath) {
1546
+ console.log(chalk.gray(`\n Full report: ${releaseDoctor.reportPath}`));
1547
+ }
1548
+ if (!noExit) process.exit(1);
1549
+ return {
1550
+ ...publishResult,
1551
+ success: false,
1552
+ error: "Release doctor failed",
1553
+ };
1554
+ }
1555
+
1556
+ console.log(chalk.green(" ✔ Release doctor passed\n"));
1557
+ } else {
1558
+ publishResult.releaseDoctor = {
1559
+ skipped: true,
1560
+ success: true,
1561
+ reason: "config-unavailable",
1562
+ };
1563
+ }
1564
+
1308
1565
  // Determine storage mode and validate configuration
1309
1566
  const storageConfig = docSyncConfig?.storage;
1310
1567
  const storageMode = getStorageMode(docSyncConfig);
@@ -1324,7 +1581,8 @@ async function publishCommand(options = {}) {
1324
1581
  console.log(chalk.red(` • ${error}`));
1325
1582
  }
1326
1583
  console.log(getStorageSetupHelp(storageConfig?.type || "reshot"));
1327
- process.exit(1);
1584
+ if (!noExit) process.exit(1);
1585
+ return { ...publishResult, success: false, error: "Invalid storage configuration" };
1328
1586
  }
1329
1587
 
1330
1588
  // Resolve project context based on storage mode
@@ -1343,7 +1601,8 @@ async function publishCommand(options = {}) {
1343
1601
  );
1344
1602
  console.log(getStorageSetupHelp("s3"));
1345
1603
  }
1346
- process.exit(1);
1604
+ if (!noExit) process.exit(1);
1605
+ return { ...publishResult, success: false, error: error.message };
1347
1606
  }
1348
1607
 
1349
1608
  const { apiKey, projectId, storageMode: resolvedMode } = projectContext;
@@ -1368,8 +1627,6 @@ async function publishCommand(options = {}) {
1368
1627
  // Get feature toggles
1369
1628
  const features = docSyncConfig?._metadata?.features || {
1370
1629
  visuals: true,
1371
- docs: false,
1372
- changelog: true,
1373
1630
  };
1374
1631
 
1375
1632
  // Get git information
@@ -1388,7 +1645,7 @@ async function publishCommand(options = {}) {
1388
1645
  });
1389
1646
  console.log(chalk.green(` ✔ Version tag "${tag}" created`));
1390
1647
  console.log(
1391
- chalk.gray(` Pinned URL: cdn.reshot.dev/assets/{key}?tag=${tag}\n`),
1648
+ chalk.gray(` Pinned URL: cdn.reshot.dev/v1/assets/{projectId}/{visualKey}?tag=${tag}\n`),
1392
1649
  );
1393
1650
  } catch (tagError) {
1394
1651
  console.log(
@@ -1401,9 +1658,51 @@ async function publishCommand(options = {}) {
1401
1658
  if (features.visuals === true) {
1402
1659
  const projectRoot = process.cwd();
1403
1660
  const outputBaseDir = path.join(projectRoot, ".reshot", "output");
1404
- const screenshotFiles = fs.existsSync(outputBaseDir)
1405
- ? findAssetFiles(outputBaseDir)
1406
- : [];
1661
+ let screenshotFiles = [];
1662
+ let screenshotScopeLabel = "all-output";
1663
+ if (fs.existsSync(outputBaseDir)) {
1664
+ if (allOutput) {
1665
+ screenshotFiles = findAssetFiles(outputBaseDir);
1666
+ } else {
1667
+ const usable = getLatestUsableRunManifest();
1668
+ if (!usable) {
1669
+ throw new Error(
1670
+ "No run manifest with successful scenarios found. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
1671
+ );
1672
+ }
1673
+ const { manifest: latestRunManifest, isFallback, isPartialSuccess } = usable;
1674
+
1675
+ if (isFallback) {
1676
+ const age = latestRunManifest.generatedAt
1677
+ ? `generated ${latestRunManifest.generatedAt}`
1678
+ : "unknown age";
1679
+ console.log(
1680
+ chalk.yellow(
1681
+ `\n WARNING: Latest run has no successful scenarios. Falling back to older manifest (${age}).` +
1682
+ `\n The published screenshots may be STALE. Run \`reshot run\` to capture fresh screenshots.\n`,
1683
+ ),
1684
+ );
1685
+ } else if (isPartialSuccess) {
1686
+ const allScenarios = latestRunManifest.scenarios || [];
1687
+ const failed = allScenarios.filter((s) => s.success === false);
1688
+ const succeeded = allScenarios.filter((s) => s.success !== false);
1689
+ console.log(
1690
+ chalk.yellow(
1691
+ `\n WARNING: Latest run had partial failures (${succeeded.length} passed, ${failed.length} failed).` +
1692
+ `\n Publishing only the ${succeeded.length} successful scenario(s).` +
1693
+ `\n Failed: ${failed.map((s) => s.key).join(", ")}\n`,
1694
+ ),
1695
+ );
1696
+ }
1697
+
1698
+ const manifestScoped = resolveManifestScopedScreenshotFiles(
1699
+ outputBaseDir,
1700
+ latestRunManifest,
1701
+ );
1702
+ screenshotFiles = manifestScoped.screenshotFiles;
1703
+ screenshotScopeLabel = `${manifestScoped.mode}:${manifestScoped.scenarioCount}`;
1704
+ }
1705
+ }
1407
1706
  const screenshotGroups =
1408
1707
  screenshotFiles.length > 0
1409
1708
  ? groupAssetsByScenario(screenshotFiles, outputBaseDir)
@@ -1434,10 +1733,16 @@ async function publishCommand(options = {}) {
1434
1733
  ` (${screenshotFiles.length} screenshots, ${exportVideoFiles.length} videos)\n`,
1435
1734
  ),
1436
1735
  );
1736
+ console.log(
1737
+ chalk.gray(
1738
+ ` Screenshot scope: ${allOutput ? "all historical output (--all-output)" : `latest successful run (${screenshotScopeLabel})`}\n`,
1739
+ ),
1740
+ );
1437
1741
 
1438
1742
  let successCount = 0;
1439
1743
  let failCount = 0;
1440
1744
  let skippedCount = 0;
1745
+ let reviewPendingCount = 0;
1441
1746
  let viewUrl = null;
1442
1747
 
1443
1748
  // Load diff manifests for attaching diff data to screenshot assets
@@ -1477,12 +1782,14 @@ async function publishCommand(options = {}) {
1477
1782
  projectId,
1478
1783
  groupedAssets,
1479
1784
  docSyncConfig,
1480
- { commitHash, commitMessage, publishSessionId },
1785
+ { commitHash, commitMessage, publishSessionId, tag },
1481
1786
  diffManifests,
1787
+ { autoApprove },
1482
1788
  );
1483
1789
  successCount = result.successCount;
1484
1790
  failCount = result.failCount;
1485
1791
  skippedCount = result.skippedCount || 0;
1792
+ reviewPendingCount = result.reviewPendingCount || 0;
1486
1793
  viewUrl = result.viewUrl || null;
1487
1794
  } catch (txError) {
1488
1795
  // Fall back to legacy flow if transactional fails
@@ -1496,7 +1803,8 @@ async function publishCommand(options = {}) {
1496
1803
  projectId,
1497
1804
  groupedAssets,
1498
1805
  docSyncConfig,
1499
- { commitHash, commitMessage, publishSessionId },
1806
+ { commitHash, commitMessage, publishSessionId, tag },
1807
+ { autoApprove },
1500
1808
  );
1501
1809
  successCount = result.successCount;
1502
1810
  failCount = result.failCount;
@@ -1508,7 +1816,8 @@ async function publishCommand(options = {}) {
1508
1816
  projectId,
1509
1817
  groupedAssets,
1510
1818
  docSyncConfig,
1511
- { commitHash, commitMessage, publishSessionId },
1819
+ { commitHash, commitMessage, publishSessionId, tag },
1820
+ { autoApprove },
1512
1821
  );
1513
1822
  successCount = result.successCount;
1514
1823
  failCount = result.failCount;
@@ -1532,164 +1841,75 @@ async function publishCommand(options = {}) {
1532
1841
  }
1533
1842
  if (failCount > 0) {
1534
1843
  console.log(chalk.red(` ✖ Failed: ${failCount}`));
1844
+ const failedKeys = viewUrl ? [] : (publishResult.failedUploadKeys || []);
1845
+ if (failedKeys.length > 0) {
1846
+ for (const key of failedKeys.slice(0, 5)) {
1847
+ console.log(chalk.red(` - ${key}`));
1848
+ }
1849
+ if (failedKeys.length > 5) {
1850
+ console.log(chalk.red(` ... and ${failedKeys.length - 5} more`));
1851
+ }
1852
+ }
1535
1853
  }
1536
1854
  if (viewUrl) {
1537
1855
  console.log(chalk.cyan(`\n 🔗 View in platform: ${viewUrl}`));
1538
1856
  }
1539
1857
 
1540
- // Helpful guidance about diff percentages
1541
- if (diffManifests && diffManifests.size > 0) {
1542
- console.log(
1543
- chalk.gray(
1544
- "\n 💡 Diff data included! View change percentages in the platform.",
1545
- ),
1546
- );
1547
- } else {
1548
- console.log(
1549
- chalk.gray(
1550
- "\n 💡 Tip: Run 'reshot run' before publish to see change percentages.",
1551
- ),
1552
- );
1553
- }
1554
- }
1555
- } else {
1556
- console.log(
1557
- chalk.yellow(" ⚠ Visual asset publishing is disabled for this project."),
1558
- );
1559
- }
1560
-
1561
- // Stream B: Documentation
1562
- if (features.docs === true) {
1563
- console.log(chalk.cyan("\n📚 Publishing documentation...\n"));
1564
-
1565
- if (!docSyncConfig || !docSyncConfig.docs) {
1566
- console.log(
1567
- chalk.yellow(
1568
- " ⚠ No docs configuration found in docsync.config.json. Skipping docs stream.",
1569
- ),
1570
- );
1571
- } else {
1572
- const docsConfig = docSyncConfig.docs;
1573
- const docsRoot = docsConfig.root || "./docs";
1574
- const includePatterns = docsConfig.include || ["**/*.md", "**/*.mdx"];
1575
- const excludePatterns = docsConfig.exclude || [
1576
- "**/node_modules/**",
1577
- "**/.git/**",
1578
- ];
1579
-
1580
- const docFiles = findDocFiles(docsRoot, includePatterns, excludePatterns);
1581
-
1582
- if (docFiles.length === 0) {
1583
- console.log(
1584
- chalk.yellow(" ⚠ No documentation files found to publish."),
1585
- );
1586
- } else {
1587
- console.log(
1588
- chalk.cyan(` Found ${docFiles.length} documentation file(s)\n`),
1589
- );
1590
-
1591
- const docsPayload = [];
1592
- for (const docFile of docFiles) {
1593
- const content = fs.readFileSync(docFile.fullPath, "utf-8");
1594
- const { frontmatter, content: docContent } =
1595
- parseFrontmatter(content);
1596
-
1597
- docsPayload.push({
1598
- path: docFile.relativePath,
1599
- content: docContent,
1600
- frontmatter:
1601
- Object.keys(frontmatter).length > 0 ? frontmatter : undefined,
1602
- status: frontmatter.status || "draft",
1603
- });
1604
- }
1605
-
1606
- // Docs publishing requires platform (no BYOS support yet)
1607
- if (resolvedMode === "byos") {
1608
- console.log(
1609
- chalk.yellow(
1610
- " ⚠ Documentation publishing requires Reshot platform.",
1611
- ),
1612
- );
1858
+ if (!autoApprove && successCount > 0) {
1859
+ // Only NEW/CHANGED captures await review; unchanged captures dedup to
1860
+ // their existing (often already-approved) version and stay live. Report
1861
+ // the accurate pending count so a re-publish doesn't look like it reset
1862
+ // already-approved work (audit run-10 F1).
1863
+ const pending = reviewPendingCount;
1864
+ const keptLive = Math.max(0, successCount - pending);
1865
+ if (pending > 0) {
1613
1866
  console.log(
1614
- chalk.gray(
1615
- " Run 'reshot auth' to enable doc hosting with review workflow.",
1867
+ chalk.cyan(
1868
+ `\n ℹ ${pending} new/changed visual${pending === 1 ? "" : "s"} awaiting review (PENDING)`,
1616
1869
  ),
1617
1870
  );
1618
- } else {
1619
- try {
1620
- const result = await apiClient.publishDocs(apiKey, {
1621
- projectId,
1622
- docs: docsPayload,
1623
- });
1871
+ if (keptLive > 0) {
1624
1872
  console.log(
1625
- chalk.green(
1626
- ` Published ${result.created || 0} new doc(s), updated ${
1627
- result.updated || 0
1628
- } doc(s)`,
1873
+ chalk.gray(
1874
+ ` ${keptLive} unchanged visual${keptLive === 1 ? "" : "s"} kept current state (live pointers preserved; no re-approval needed).`,
1629
1875
  ),
1630
1876
  );
1631
- } catch (error) {
1632
- console.log(
1633
- chalk.red(` ✖ Failed to publish docs: ${error.message}`),
1634
- );
1635
1877
  }
1636
- }
1637
- }
1638
- }
1639
- } else {
1640
- console.log(
1641
- chalk.yellow(
1642
- " ⚠ Documentation publishing is disabled for this project.",
1643
- ),
1644
- );
1645
- }
1646
-
1647
- // Stream C: Changelog
1648
- if (features.changelog === true) {
1649
- // Changelog requires platform (no BYOS support)
1650
- if (resolvedMode === "byos") {
1651
- console.log(chalk.cyan("\n📝 Changelog drafts...\n"));
1652
- console.log(
1653
- chalk.yellow(
1654
- " ⚠ Changelog requires Reshot platform for tracking and publishing.",
1655
- ),
1656
- );
1657
- console.log(
1658
- chalk.gray(" Run 'reshot auth' to enable changelog generation."),
1659
- );
1660
- } else {
1661
- console.log(chalk.cyan("\n📝 Posting changelog drafts...\n"));
1662
- const lastPublishedHash = settings?.lastPublishedCommitHash;
1663
- const recentCommits = getRecentCommits(lastPublishedHash);
1664
-
1665
- if (recentCommits.length > 0) {
1666
- try {
1667
- await apiClient.postChangelogDrafts(projectId, recentCommits, apiKey);
1668
1878
  console.log(
1669
- chalk.green(
1670
- ` ✔ Posted ${recentCommits.length} changelog draft(s)`,
1879
+ chalk.gray(
1880
+ " To skip review for first-time captures, re-run with `reshot publish --auto-approve`",
1671
1881
  ),
1672
1882
  );
1673
-
1674
- // Update last published commit hash
1675
- if (settings) {
1676
- settings.lastPublishedCommitHash = commitHash;
1677
- config.writeSettings(settings);
1678
- }
1679
- } catch (error) {
1680
1883
  console.log(
1681
- chalk.yellow(
1682
- ` ⚠ Failed to post changelog drafts: ${error.message}`,
1884
+ chalk.gray(" or approve them in the studio link above."),
1885
+ );
1886
+ } else {
1887
+ console.log(
1888
+ chalk.cyan(
1889
+ `\n ℹ No new captures to review — all ${successCount} published visual${successCount === 1 ? "" : "s"} were unchanged; existing live pointers are preserved (already-approved stay live, pending stay pending).`,
1683
1890
  ),
1684
1891
  );
1685
1892
  }
1893
+ }
1894
+
1895
+ // Helpful guidance about diff percentages
1896
+ if (diffManifests && diffManifests.size > 0) {
1897
+ console.log(
1898
+ chalk.gray(
1899
+ "\n 💡 Diff data included! View change percentages in the platform.",
1900
+ ),
1901
+ );
1686
1902
  } else {
1687
- console.log(chalk.gray(" No new commits to publish"));
1903
+ console.log(
1904
+ chalk.gray(
1905
+ "\n 💡 Tip: Run 'reshot run' before publish to see change percentages.",
1906
+ ),
1907
+ );
1688
1908
  }
1689
1909
  }
1690
1910
  } else {
1691
1911
  console.log(
1692
- chalk.yellow(" ⚠ Changelog publishing is disabled for this project."),
1912
+ chalk.yellow(" ⚠ Visual asset publishing is disabled for this project."),
1693
1913
  );
1694
1914
  }
1695
1915
 
@@ -1700,7 +1920,7 @@ async function publishCommand(options = {}) {
1700
1920
  console.log(chalk.gray(" • Unbreakable URLs that never change"));
1701
1921
  console.log(chalk.gray(" • Version history and rollback"));
1702
1922
  console.log(chalk.gray(" • Team collaboration and RBAC"));
1703
- console.log(chalk.gray(" • Automatic changelog generation"));
1923
+ console.log(chalk.gray(" • Drift detection and notifications"));
1704
1924
  console.log(chalk.gray("\n Run 'reshot auth' to connect your project."));
1705
1925
  }
1706
1926
 
@@ -1715,7 +1935,14 @@ async function publishCommand(options = {}) {
1715
1935
 
1716
1936
  console.log();
1717
1937
 
1718
- return publishResult;
1938
+ return {
1939
+ ...publishResult,
1940
+ success: publishResult.assetsFailed === 0 && publishResult.assetsProcessed > 0,
1941
+ };
1719
1942
  }
1720
1943
 
1721
1944
  module.exports = publishCommand;
1945
+ module.exports.groupAssetsByScenario = groupAssetsByScenario;
1946
+ module.exports.resolveManifestScopedScreenshotFiles =
1947
+ resolveManifestScopedScreenshotFiles;
1948
+ module.exports.collectAssetFilesFromDirectories = collectAssetFilesFromDirectories;