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

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
@@ -1,5 +1,5 @@
1
1
  // pull.js - Pull asset map from Reshot platform
2
- // Generates JSON, TypeScript, or CSV files for consumption in build pipelines
2
+ // Generates JSON, TypeScript, or CSV files for local workflows
3
3
  const chalk = require("chalk");
4
4
  const fs = require("fs-extra");
5
5
  const path = require("path");
@@ -16,6 +16,160 @@ function toCamelCase(str) {
16
16
  .replace(/^[A-Z]/, (chr) => chr.toLowerCase());
17
17
  }
18
18
 
19
+ function normalizeHostedAssetUrl(urlString) {
20
+ if (!urlString || typeof urlString !== "string") {
21
+ return urlString;
22
+ }
23
+
24
+ let normalized = urlString;
25
+ if (!normalized.includes("?") && normalized.includes("&")) {
26
+ const firstAmp = normalized.indexOf("&");
27
+ normalized = `${normalized.slice(0, firstAmp)}?${normalized.slice(firstAmp + 1)}`;
28
+ }
29
+
30
+ const parsed = new URL(normalized);
31
+ const capture = parsed.searchParams.get("capture");
32
+ if (capture && !parsed.searchParams.get("step")) {
33
+ parsed.searchParams.set("step", capture);
34
+ }
35
+ parsed.searchParams.delete("capture");
36
+
37
+ return parsed.toString();
38
+ }
39
+
40
+ function normalizeHostedAssetUrlWithMeta(urlString) {
41
+ const normalized = normalizeHostedAssetUrl(urlString);
42
+ return {
43
+ url: normalized,
44
+ repaired: normalized !== urlString,
45
+ };
46
+ }
47
+
48
+ function normalizeAssetEntry(entry) {
49
+ if (!entry || typeof entry !== "object") {
50
+ return { entry, repairs: 0 };
51
+ }
52
+
53
+ if (Array.isArray(entry.steps)) {
54
+ return {
55
+ entry: {
56
+ ...entry,
57
+ steps: entry.steps.map((step) => {
58
+ const src = normalizeHostedAssetUrlWithMeta(step.src);
59
+ const poster = step.poster
60
+ ? normalizeHostedAssetUrlWithMeta(step.poster)
61
+ : null;
62
+ return {
63
+ ...step,
64
+ src: src.url,
65
+ poster: poster ? poster.url : step.poster,
66
+ };
67
+ }),
68
+ },
69
+ repairs: entry.steps.reduce((count, step) => {
70
+ const src = normalizeHostedAssetUrlWithMeta(step.src);
71
+ const poster = step.poster
72
+ ? normalizeHostedAssetUrlWithMeta(step.poster)
73
+ : null;
74
+ return count + (src.repaired ? 1 : 0) + (poster?.repaired ? 1 : 0);
75
+ }, 0),
76
+ };
77
+ }
78
+
79
+ return {
80
+ entry: {
81
+ ...entry,
82
+ src: entry.src ? normalizeHostedAssetUrl(entry.src) : entry.src,
83
+ poster: entry.poster ? normalizeHostedAssetUrl(entry.poster) : entry.poster,
84
+ },
85
+ repairs:
86
+ (entry.src && normalizeHostedAssetUrlWithMeta(entry.src).repaired ? 1 : 0) +
87
+ (entry.poster && normalizeHostedAssetUrlWithMeta(entry.poster).repaired ? 1 : 0),
88
+ };
89
+ }
90
+
91
+ function normalizeAssetMap(assets) {
92
+ const normalized = {};
93
+ let repairs = 0;
94
+
95
+ for (const [group, visuals] of Object.entries(assets || {})) {
96
+ normalized[group] = {};
97
+
98
+ for (const [visualKey, variants] of Object.entries(visuals || {})) {
99
+ normalized[group][visualKey] = {};
100
+
101
+ for (const [variant, entry] of Object.entries(variants || {})) {
102
+ const normalizedEntry = normalizeAssetEntry(entry);
103
+ normalized[group][visualKey][variant] = normalizedEntry.entry;
104
+ repairs += normalizedEntry.repairs;
105
+ }
106
+ }
107
+ }
108
+
109
+ return { assets: normalized, repairs };
110
+ }
111
+
112
+ /**
113
+ * Pick a real {group, visualKey, context} from a normalized asset map so the
114
+ * printed usage example is copy-pasteable for THIS project (prefers the
115
+ * "default" context). Returns null if the map is empty.
116
+ */
117
+ function deriveUsageSample(assets) {
118
+ for (const group of Object.keys(assets || {})) {
119
+ for (const visualKey of Object.keys(assets[group] || {})) {
120
+ const contexts = Object.keys(assets[group][visualKey] || {});
121
+ if (contexts.length === 0) continue;
122
+ const context = contexts.includes("default") ? "default" : contexts[0];
123
+ // A capture entry is either flat ({src,width,height}) or step-based
124
+ // ({steps:[{src,...}]}). Tell the caller which, so the printed example
125
+ // references the right path (`.steps[0].src` vs `.src`) and doesn't
126
+ // produce an undefined src (audit run-12 MED-1).
127
+ const entry = assets[group][visualKey][context];
128
+ const stepped = !!(entry && Array.isArray(entry.steps));
129
+ return { group, visualKey, context, stepped };
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function validateHostedAssetUrl(urlString) {
136
+ if (!urlString || typeof urlString !== "string") {
137
+ return;
138
+ }
139
+
140
+ if (!urlString.includes("?") && (urlString.includes("&step=") || urlString.includes("&poster="))) {
141
+ throw new Error(`Malformed asset URL emitted without query delimiter: ${urlString}`);
142
+ }
143
+
144
+ const parsed = new URL(urlString);
145
+ if (parsed.searchParams.has("capture")) {
146
+ throw new Error(`Legacy capture param emitted in normalized output: ${urlString}`);
147
+ }
148
+ }
149
+
150
+ function validateAssetMap(assets) {
151
+ for (const visuals of Object.values(assets || {})) {
152
+ for (const variants of Object.values(visuals || {})) {
153
+ for (const entry of Object.values(variants || {})) {
154
+ if (entry && typeof entry === "object" && Array.isArray(entry.steps)) {
155
+ for (const step of entry.steps) {
156
+ validateHostedAssetUrl(step.src);
157
+ if (step.poster) {
158
+ validateHostedAssetUrl(step.poster);
159
+ }
160
+ }
161
+ continue;
162
+ }
163
+
164
+ validateHostedAssetUrl(entry?.src);
165
+ if (entry?.poster) {
166
+ validateHostedAssetUrl(entry.poster);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+
19
173
  /**
20
174
  * Generate TypeScript code from the assets object
21
175
  * This provides type safety - if a visual is deleted, the build will fail
@@ -39,12 +193,27 @@ function generateTypeScript(assets, includeMetadata = false) {
39
193
 
40
194
  for (const [variant, asset] of Object.entries(variants)) {
41
195
  lines.push(` ${variant}: {`);
42
- lines.push(` src: "${asset.src}",`);
43
196
  lines.push(` type: "${asset.type}",`);
44
- if (asset.width) lines.push(` width: ${asset.width},`);
45
- if (asset.height) lines.push(` height: ${asset.height},`);
46
197
  lines.push(` alt: "${asset.alt.replace(/"/g, '\\"')}",`);
47
- if (asset.poster) lines.push(` poster: "${asset.poster}",`);
198
+ if (Array.isArray(asset.steps)) {
199
+ lines.push(` steps: [`);
200
+ for (const step of asset.steps) {
201
+ lines.push(" {");
202
+ lines.push(` step: "${step.step}",`);
203
+ lines.push(` src: "${step.src}",`);
204
+ lines.push(` type: "${step.type}",`);
205
+ if (step.width) lines.push(` width: ${step.width},`);
206
+ if (step.height) lines.push(` height: ${step.height},`);
207
+ if (step.poster) lines.push(` poster: "${step.poster}",`);
208
+ lines.push(" },");
209
+ }
210
+ lines.push(" ],");
211
+ } else {
212
+ lines.push(` src: "${asset.src}",`);
213
+ if (asset.width) lines.push(` width: ${asset.width},`);
214
+ if (asset.height) lines.push(` height: ${asset.height},`);
215
+ if (asset.poster) lines.push(` poster: "${asset.poster}",`);
216
+ }
48
217
  lines.push(` },`);
49
218
  }
50
219
 
@@ -67,11 +236,19 @@ function generateTypeScript(assets, includeMetadata = false) {
67
236
  for (const [visualKey, variants] of Object.entries(visuals)) {
68
237
  // If there's only a default variant, flatten it
69
238
  if (Object.keys(variants).length === 1 && variants.default) {
70
- lines.push(` ${visualKey}: "${variants.default.src}",`);
239
+ if (Array.isArray(variants.default.steps)) {
240
+ lines.push(` ${visualKey}: ${JSON.stringify(variants.default.steps)},`);
241
+ } else {
242
+ lines.push(` ${visualKey}: "${variants.default.src}",`);
243
+ }
71
244
  } else {
72
245
  lines.push(` ${visualKey}: {`);
73
246
  for (const [variant, asset] of Object.entries(variants)) {
74
- lines.push(` ${variant}: "${asset.src}",`);
247
+ if (Array.isArray(asset.steps)) {
248
+ lines.push(` ${variant}: ${JSON.stringify(asset.steps)},`);
249
+ } else {
250
+ lines.push(` ${variant}: "${asset.src}",`);
251
+ }
75
252
  }
76
253
  lines.push(` },`);
77
254
  }
@@ -101,7 +278,8 @@ async function pullCommand(options = {}) {
101
278
  format = "json",
102
279
  output = null,
103
280
  full = false,
104
- status = "approved",
281
+ status = "all",
282
+ noExit = false,
105
283
  } = options;
106
284
 
107
285
  console.log(chalk.blue("⬇ Pulling asset map from Reshot...\n"));
@@ -112,17 +290,33 @@ async function pullCommand(options = {}) {
112
290
  projectConfig = config.readConfig();
113
291
  } catch (e) {
114
292
  console.error(
115
- chalk.red("Error: No docsync.config.json found. Run 'reshot init' first.")
293
+ chalk.red("Error: No reshot.config.json found. Run 'reshot init' first.")
116
294
  );
117
- process.exit(1);
295
+ if (!noExit) process.exit(1);
296
+ return { success: false, error: "No reshot.config.json found" };
118
297
  }
119
298
 
120
- const projectId = projectConfig._metadata?.projectId || projectConfig.projectId;
299
+ let projectId =
300
+ process.env.RESHOT_PROJECT_ID ||
301
+ projectConfig._metadata?.projectId ||
302
+ projectConfig.projectId;
121
303
  if (!projectId) {
122
- console.error(
123
- chalk.red("Error: No projectId found in docsync.config.json")
124
- );
125
- process.exit(1);
304
+ // Fall back to reading projectId from .reshot/settings.json (written by `reshot auth`)
305
+ try {
306
+ const settings = config.readSettings();
307
+ if (settings?.projectId) {
308
+ projectId = settings.projectId;
309
+ }
310
+ } catch (e) {
311
+ // No settings file either
312
+ }
313
+ }
314
+ if (!projectId) {
315
+ console.error(
316
+ chalk.red("Error: No projectId found. Add it to reshot.config.json or run 'reshot auth' first.")
317
+ );
318
+ if (!noExit) process.exit(1);
319
+ return { success: false, error: "No projectId found" };
126
320
  }
127
321
 
128
322
  // Check authentication
@@ -133,13 +327,28 @@ async function pullCommand(options = {}) {
133
327
  console.error(
134
328
  chalk.red("Error: Not authenticated. Run 'reshot auth' first.")
135
329
  );
136
- process.exit(1);
330
+ if (!noExit) process.exit(1);
331
+ return { success: false, error: "Not authenticated" };
137
332
  }
138
333
  if (!settings?.apiKey) {
139
334
  console.error(
140
335
  chalk.red("Error: Not authenticated. Run 'reshot auth' first.")
141
336
  );
142
- process.exit(1);
337
+ if (!noExit) process.exit(1);
338
+ return { success: false, error: "Not authenticated" };
339
+ }
340
+
341
+ if (settings.projectId && projectId !== settings.projectId) {
342
+ console.warn(
343
+ chalk.yellow(
344
+ ` ⚠ Project ID mismatch: active project is ${projectId}, but authenticated project is ${settings.projectId}.`
345
+ )
346
+ );
347
+ console.warn(
348
+ chalk.yellow(
349
+ ` This will cause auth failures. Run 'reshot setup --force' to re-link.\n`
350
+ )
351
+ );
143
352
  }
144
353
 
145
354
  try {
@@ -154,16 +363,34 @@ async function pullCommand(options = {}) {
154
363
 
155
364
  if (!response || !response.assets) {
156
365
  console.error(chalk.red("Error: Invalid response from API"));
157
- process.exit(1);
366
+ if (!noExit) process.exit(1);
367
+ return { success: false, error: "Invalid response from API" };
158
368
  }
159
369
 
160
- const { meta, assets } = response;
161
- const assetCount = Object.values(assets).reduce(
370
+ const normalization = normalizeAssetMap(response.assets);
371
+ const normalizedAssets = normalization.assets;
372
+ validateAssetMap(normalizedAssets);
373
+
374
+ const { meta } = response;
375
+ const assetCount = Object.values(normalizedAssets).reduce(
162
376
  (count, group) => count + Object.keys(group).length,
163
377
  0
164
378
  );
165
379
 
166
- console.log(chalk.green(` ✓ Fetched ${assetCount} visuals\n`));
380
+ if (assetCount === 0) {
381
+ console.log(chalk.yellow(` ⚠ Fetched 0 visuals.\n`));
382
+ console.log(chalk.gray(` Possible reasons:`));
383
+ if (status === "approved") {
384
+ console.log(chalk.gray(` • No visuals approved yet. Try: reshot pull --status all`));
385
+ } else if (status === "pending") {
386
+ console.log(chalk.gray(` • No pending visuals. Try: reshot pull --status all`));
387
+ }
388
+ console.log(chalk.gray(` • No visuals published. Run: reshot publish`));
389
+ console.log(chalk.gray(` • Wrong project. Active project: ${projectId}`));
390
+ console.log(chalk.gray(` • Check: ${settings.platformUrl || "https://reshot.dev"}\n`));
391
+ } else {
392
+ console.log(chalk.green(` ✓ Fetched ${assetCount} visuals\n`));
393
+ }
167
394
 
168
395
  // Determine output path
169
396
  let outputPath = output;
@@ -187,7 +414,7 @@ async function pullCommand(options = {}) {
187
414
 
188
415
  switch (format) {
189
416
  case "ts":
190
- content = generateTypeScript(assets, full);
417
+ content = generateTypeScript(normalizedAssets, full);
191
418
  contentType = "TypeScript";
192
419
  break;
193
420
 
@@ -196,9 +423,28 @@ async function pullCommand(options = {}) {
196
423
  const headers = ["Key", "Group", "Name", "Context", "Type", "Unbreakable_URL", "Width", "Height"];
197
424
  const rows = [headers.join(",")];
198
425
 
199
- for (const [group, visuals] of Object.entries(assets)) {
426
+ for (const [group, visuals] of Object.entries(normalizedAssets)) {
200
427
  for (const [visualKey, variants] of Object.entries(visuals)) {
201
428
  for (const [variant, asset] of Object.entries(variants)) {
429
+ if (Array.isArray(asset.steps)) {
430
+ for (const step of asset.steps) {
431
+ const type = step.type.split("/")[0];
432
+ rows.push(
433
+ [
434
+ `${group}/${visualKey}/${step.step}`,
435
+ group,
436
+ visualKey,
437
+ variant,
438
+ type,
439
+ step.src,
440
+ step.width || "",
441
+ step.height || "",
442
+ ].join(",")
443
+ );
444
+ }
445
+ continue;
446
+ }
447
+
202
448
  const type = asset.type.split("/")[0]; // 'image' or 'video'
203
449
  rows.push(
204
450
  [
@@ -222,7 +468,7 @@ async function pullCommand(options = {}) {
222
468
 
223
469
  case "json":
224
470
  default:
225
- content = JSON.stringify({ meta, assets }, null, 2);
471
+ content = JSON.stringify({ meta, assets: normalizedAssets }, null, 2);
226
472
  contentType = "JSON";
227
473
  break;
228
474
  }
@@ -233,6 +479,16 @@ async function pullCommand(options = {}) {
233
479
  console.log(chalk.green(`✓ Generated ${contentType} file: ${outputPath}`));
234
480
  console.log(chalk.gray(` ${assetCount} visuals exported\n`));
235
481
 
482
+ // Derive a REAL key path from the pulled map so the printed usage example
483
+ // is copy-pasteable (was hardcoded to a non-existent `dashboard.mainView`).
484
+ const sample = deriveUsageSample(normalizedAssets);
485
+ const sGroup = sample?.group ?? "dashboard";
486
+ const sVisual = sample?.visualKey ?? "mainView";
487
+ const sCtx = sample?.context ?? "default";
488
+ // Step-based capture entries nest the fields under `steps[0]`; reference it
489
+ // so the printed example's src/width/height aren't undefined (run-12 MED-1).
490
+ const sStep = sample?.stepped ? ".steps[0]" : "";
491
+
236
492
  // Show format-specific usage instructions
237
493
  if (format === "ts") {
238
494
  console.log(chalk.blue("━━━ TypeScript Usage ━━━\n"));
@@ -240,10 +496,9 @@ async function pullCommand(options = {}) {
240
496
  console.log(chalk.cyan(' import { ReshotVisuals } from "./visuals";\n'));
241
497
  console.log(chalk.white("Use with full autocomplete support:\n"));
242
498
  console.log(chalk.cyan(" // Single context (flattened)"));
243
- console.log(chalk.cyan(" <img src={ReshotVisuals.dashboard.mainView} />\n"));
499
+ console.log(chalk.cyan(` <img src={ReshotVisuals.${sGroup}.${sVisual}} />\n`));
244
500
  console.log(chalk.cyan(" // Multiple contexts (dark mode, locales)"));
245
- console.log(chalk.cyan(" <img src={ReshotVisuals.dashboard.mainView.default} />"));
246
- console.log(chalk.cyan(" <img src={ReshotVisuals.dashboard.mainView.dark} />\n"));
501
+ console.log(chalk.cyan(` <img src={ReshotVisuals.${sGroup}.${sVisual}.${sCtx}} />\n`));
247
502
  if (!full) {
248
503
  console.log(chalk.gray(" Tip: Use --full flag to include width, height, and alt text\n"));
249
504
  }
@@ -253,10 +508,10 @@ async function pullCommand(options = {}) {
253
508
  console.log(chalk.cyan(' import assets from "./reshot-assets.json";\n'));
254
509
  console.log(chalk.white("Access assets with dot notation:\n"));
255
510
  console.log(chalk.cyan(" <img"));
256
- console.log(chalk.cyan(" src={assets.assets.dashboard.mainView.default.src}"));
257
- console.log(chalk.cyan(" width={assets.assets.dashboard.mainView.default.width}"));
258
- console.log(chalk.cyan(" height={assets.assets.dashboard.mainView.default.height}"));
259
- console.log(chalk.cyan(" alt={assets.assets.dashboard.mainView.default.alt}"));
511
+ console.log(chalk.cyan(` src={assets.assets.${sGroup}.${sVisual}.${sCtx}${sStep}.src}`));
512
+ console.log(chalk.cyan(` width={assets.assets.${sGroup}.${sVisual}.${sCtx}${sStep}.width}`));
513
+ console.log(chalk.cyan(` height={assets.assets.${sGroup}.${sVisual}.${sCtx}${sStep}.height}`));
514
+ console.log(chalk.cyan(` alt={assets.assets.${sGroup}.${sVisual}.${sCtx}${sStep}.alt}`));
260
515
  console.log(chalk.cyan(" />\n"));
261
516
  } else if (format === "csv") {
262
517
  console.log(chalk.blue("━━━ CSV Usage ━━━\n"));
@@ -275,9 +530,9 @@ async function pullCommand(options = {}) {
275
530
  console.log(chalk.gray(" • Width/Height - Dimensions in pixels\n"));
276
531
  }
277
532
 
278
- // CI/CD integration tip
279
- console.log(chalk.blue("━━━ CI/CD Integration ━━━\n"));
280
- console.log(chalk.white("Add to your build pipeline:\n"));
533
+ // Workflow integration tip
534
+ console.log(chalk.blue("━━━ Workflow Integration ━━━\n"));
535
+ console.log(chalk.white("Add to your local workflow:\n"));
281
536
  console.log(chalk.cyan(" # package.json"));
282
537
  console.log(chalk.cyan(' "scripts": {'));
283
538
  console.log(chalk.cyan(` "prebuild": "npx reshot pull --format=${format}${output ? ` --output=${output}` : ''}"`));
@@ -285,6 +540,15 @@ async function pullCommand(options = {}) {
285
540
  console.log(chalk.gray(" This ensures your build always uses the latest approved visuals."));
286
541
  console.log(chalk.gray(" If a visual is deleted, your build will fail (preventing broken images).\n"));
287
542
 
543
+ return {
544
+ success: true,
545
+ assetCount,
546
+ outputPath: path.resolve(outputPath),
547
+ normalizedAssets,
548
+ normalizationRepairs: normalization.repairs,
549
+ meta,
550
+ };
551
+
288
552
  } catch (error) {
289
553
  if (config.isAuthError(error)) {
290
554
  console.error(
@@ -296,7 +560,10 @@ async function pullCommand(options = {}) {
296
560
  console.error(chalk.gray(error.stack));
297
561
  }
298
562
  }
299
- process.exit(1);
563
+ if (!noExit) {
564
+ process.exit(1);
565
+ }
566
+ return { success: false, error: error.message };
300
567
  }
301
568
  }
302
569
 
@@ -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 };