@reshotdev/screenshot 0.0.1-beta.0

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 (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,4677 @@
1
+ // ui-api.js - Internal REST API for Reshot UI
2
+ const express = require("express");
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+ const config = require("./config");
6
+ const apiClient = require("./api-client");
7
+ const {
8
+ findAssetFiles,
9
+ groupAssetsByScenario,
10
+ getVersionsPerScenario,
11
+ } = require("./ui-assets");
12
+ const {
13
+ getTemplatePresets,
14
+ validateTemplate,
15
+ parseTemplateVariables,
16
+ } = require("./output-path-template");
17
+ const {
18
+ getAllViewportPresets,
19
+ getViewportPresetsByCategory,
20
+ getAllCropPresets,
21
+ resolveViewport,
22
+ validateViewport,
23
+ } = require("./viewport-presets");
24
+ const {
25
+ isStandaloneMode,
26
+ getAvailableFeatures,
27
+ printModeStatus,
28
+ } = require("./standalone-mode");
29
+ const {
30
+ validatePrivacyConfig,
31
+ DEFAULT_PRIVACY_CONFIG,
32
+ } = require("./privacy-engine");
33
+ const {
34
+ validateStyleConfig,
35
+ DEFAULT_STYLE_CONFIG,
36
+ applyStyle,
37
+ isStyleAvailable,
38
+ } = require("./style-engine");
39
+
40
+ /**
41
+ * Get the platform URL from settings, falling back to localhost for development
42
+ * @param {Object} settings - CLI settings object
43
+ * @returns {string} Platform URL
44
+ */
45
+ function getPlatformUrl(settings) {
46
+ // Priority: settings.platformUrl > env var > localhost default
47
+ if (settings?.platformUrl) {
48
+ return settings.platformUrl;
49
+ }
50
+ const envUrl =
51
+ process.env.RESHOT_API_BASE_URL || process.env.DOCSYNC_API_BASE_URL;
52
+ if (envUrl) {
53
+ // Remove /api suffix if present to get platform URL
54
+ return envUrl.replace(/\/api\/?$/, "");
55
+ }
56
+ return "http://localhost:3000";
57
+ }
58
+
59
+ /**
60
+ * Handle API errors and detect if re-auth is needed
61
+ * @param {Error} error - The error from API call
62
+ * @param {Object} res - Express response object
63
+ * @returns {Object|null} Response if error was handled, null otherwise
64
+ */
65
+ function handleApiError(error, res) {
66
+ if (config.isAuthError(error)) {
67
+ const errorMsg =
68
+ error.response?.data?.error ||
69
+ error.message ||
70
+ "API key is invalid or expired";
71
+ return res.status(401).json(config.createAuthErrorResponse(errorMsg));
72
+ }
73
+ return null; // Error not handled, let caller handle it
74
+ }
75
+
76
+ /**
77
+ * Generate all possible variant combinations from dimensions
78
+ * @param {Object} dimensions - Variant dimensions config
79
+ * @param {string[]} dimensionKeys - Which dimensions to include
80
+ * @returns {Array<Object>} Array of variant objects
81
+ */
82
+ function generateVariantCombinations(dimensions, dimensionKeys = []) {
83
+ if (!dimensions || dimensionKeys.length === 0) {
84
+ return [];
85
+ }
86
+
87
+ // Get options for each dimension
88
+ const dimensionOptions = dimensionKeys
89
+ .map((key) => {
90
+ const dim = dimensions[key];
91
+ if (!dim?.options) return [];
92
+ return Object.keys(dim.options).map((optKey) => ({
93
+ dimension: key,
94
+ option: optKey,
95
+ }));
96
+ })
97
+ .filter((opts) => opts.length > 0);
98
+
99
+ if (dimensionOptions.length === 0) {
100
+ return [];
101
+ }
102
+
103
+ // Generate cartesian product of all dimension options
104
+ const cartesian = (...arrays) => {
105
+ return arrays.reduce(
106
+ (acc, arr) => acc.flatMap((combo) => arr.map((item) => [...combo, item])),
107
+ [[]],
108
+ );
109
+ };
110
+
111
+ const combinations = cartesian(...dimensionOptions);
112
+
113
+ // Convert to variant objects
114
+ return combinations.map((combo) => {
115
+ const variant = {};
116
+ for (const { dimension, option } of combo) {
117
+ variant[dimension] = option;
118
+ }
119
+ return variant;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Validate a path segment to prevent directory traversal attacks
125
+ * @param {string} segment - Path segment to validate
126
+ * @returns {boolean} True if safe, false if potentially malicious
127
+ */
128
+ function isValidPathSegment(segment) {
129
+ if (!segment || typeof segment !== "string") return false;
130
+ // Reject empty, dots-only, or segments with path separators
131
+ if (segment === "." || segment === "..") return false;
132
+ if (segment.includes("/") || segment.includes("\\")) return false;
133
+ if (segment.includes("\0")) return false; // Null byte injection
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Validate that a resolved path stays within the expected base directory
139
+ * @param {string} resolvedPath - Fully resolved path
140
+ * @param {string} baseDir - Expected base directory
141
+ * @returns {boolean} True if path is within base, false otherwise
142
+ */
143
+ function isPathWithinBase(resolvedPath, baseDir) {
144
+ const normalizedBase = path.resolve(baseDir);
145
+ const normalizedPath = path.resolve(resolvedPath);
146
+ return (
147
+ normalizedPath.startsWith(normalizedBase + path.sep) ||
148
+ normalizedPath === normalizedBase
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Attach all API routes to an Express app
154
+ * @param {express.Application} app - Express app instance
155
+ * @param {Object} context - Context with settings
156
+ */
157
+ function attachApiRoutes(app, context) {
158
+ const { settings } = context;
159
+
160
+ // Error handler middleware
161
+ const handleError = (err, req, res, next) => {
162
+ console.error("API Error:", err);
163
+ res.status(err.status || 500).json({
164
+ error: err.message || "Internal server error",
165
+ });
166
+ };
167
+
168
+ // ===== CONFIG ENDPOINTS =====
169
+
170
+ /**
171
+ * GET /api/config
172
+ * Returns current config, settings, and derived status
173
+ */
174
+ app.get("/api/config", async (req, res, next) => {
175
+ try {
176
+ let docSyncConfig = null;
177
+ let configError = null;
178
+
179
+ if (config.configExists()) {
180
+ try {
181
+ docSyncConfig = config.readConfig();
182
+ } catch (error) {
183
+ configError = error.message;
184
+ }
185
+ }
186
+
187
+ const status = {
188
+ hasConfig: docSyncConfig !== null,
189
+ configError,
190
+ scenarioCount: docSyncConfig?.scenarios?.length || 0,
191
+ totalSteps:
192
+ docSyncConfig?.scenarios?.reduce(
193
+ (sum, s) => sum + (s.steps?.length || 0),
194
+ 0,
195
+ ) || 0,
196
+ lastSyncedAt: settings?.lastSyncedAt || null,
197
+ lastPublishedCommitHash: settings?.lastPublishedCommitHash || null,
198
+ };
199
+
200
+ res.json({
201
+ config: docSyncConfig,
202
+ settings,
203
+ status,
204
+ });
205
+ } catch (error) {
206
+ next(error);
207
+ }
208
+ });
209
+
210
+ /**
211
+ * GET /api/scenarios/metadata
212
+ * Returns scenarios with additional metadata (createdAt, lastRunAt)
213
+ * Data sourced from output directories and job history
214
+ */
215
+ app.get("/api/scenarios/metadata", async (req, res, next) => {
216
+ try {
217
+ const docSyncConfig = config.configExists()
218
+ ? config.readConfig()
219
+ : { scenarios: [] };
220
+ const scenarios = docSyncConfig?.scenarios || [];
221
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
222
+
223
+ // Get all jobs to find last run times
224
+ const allJobs = uiExecutor.getAllJobs(500);
225
+
226
+ // Build metadata for each scenario
227
+ const scenariosWithMetadata = scenarios.map((scenario) => {
228
+ let createdAt = null;
229
+ let lastRunAt = null;
230
+ let lastRunStatus = null;
231
+ let assetCount = 0;
232
+
233
+ // Try to find creation date from earliest output folder
234
+ const scenarioOutputDir = path.join(outputBaseDir, scenario.key);
235
+ if (fs.existsSync(scenarioOutputDir)) {
236
+ try {
237
+ const subFolders = fs
238
+ .readdirSync(scenarioOutputDir)
239
+ .filter((item) => {
240
+ const fullPath = path.join(scenarioOutputDir, item);
241
+ try {
242
+ return (
243
+ fs.statSync(fullPath).isDirectory() && item !== "latest"
244
+ );
245
+ } catch {
246
+ return false;
247
+ }
248
+ });
249
+
250
+ // Parse timestamps from folder names (format: YYYY-MM-DD_HH-MM-SS)
251
+ const timestamps = subFolders
252
+ .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(f))
253
+ .map((f) => {
254
+ const [date, time] = f.split("_");
255
+ const [year, month, day] = date.split("-");
256
+ const [hour, min, sec] = time.split("-");
257
+ return new Date(
258
+ `${year}-${month}-${day}T${hour}:${min}:${sec}`,
259
+ );
260
+ })
261
+ .filter((d) => !isNaN(d.getTime()))
262
+ .sort((a, b) => a.getTime() - b.getTime());
263
+
264
+ if (timestamps.length > 0) {
265
+ createdAt = timestamps[0].toISOString();
266
+ lastRunAt = timestamps[timestamps.length - 1].toISOString();
267
+ }
268
+
269
+ // Count assets in latest folder
270
+ const latestDir = path.join(scenarioOutputDir, "latest");
271
+ if (fs.existsSync(latestDir)) {
272
+ try {
273
+ const files = fs.readdirSync(latestDir);
274
+ assetCount = files.filter(
275
+ (f) =>
276
+ f.endsWith(".png") ||
277
+ f.endsWith(".jpg") ||
278
+ f.endsWith(".mp4") ||
279
+ f.endsWith(".webm"),
280
+ ).length;
281
+ } catch {}
282
+ }
283
+ } catch (err) {
284
+ // Ignore errors reading output directories
285
+ }
286
+ }
287
+
288
+ // Also check jobs for more accurate last run info
289
+ const scenarioJobs = allJobs.filter((job) => {
290
+ if (job.type !== "run") return false;
291
+ const keys = job.params?.scenarioKeys || [];
292
+ return keys.includes(scenario.key);
293
+ });
294
+
295
+ if (scenarioJobs.length > 0) {
296
+ // Sort by createdAt desc
297
+ scenarioJobs.sort(
298
+ (a, b) =>
299
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
300
+ );
301
+ const latestJob = scenarioJobs[0];
302
+
303
+ // Use job completion time if available
304
+ if (latestJob.completedAt) {
305
+ const jobTime = new Date(latestJob.completedAt).toISOString();
306
+ if (!lastRunAt || jobTime > lastRunAt) {
307
+ lastRunAt = jobTime;
308
+ lastRunStatus = latestJob.status;
309
+ }
310
+ } else if (latestJob.createdAt) {
311
+ const jobTime = new Date(latestJob.createdAt).toISOString();
312
+ if (!lastRunAt || jobTime > lastRunAt) {
313
+ lastRunAt = jobTime;
314
+ lastRunStatus = latestJob.status;
315
+ }
316
+ }
317
+
318
+ // Get earliest job for createdAt
319
+ const earliestJob = scenarioJobs[scenarioJobs.length - 1];
320
+ if (
321
+ earliestJob.createdAt &&
322
+ (!createdAt || earliestJob.createdAt < createdAt)
323
+ ) {
324
+ createdAt = earliestJob.createdAt;
325
+ }
326
+ }
327
+
328
+ return {
329
+ ...scenario,
330
+ _metadata: {
331
+ createdAt,
332
+ lastRunAt,
333
+ lastRunStatus,
334
+ assetCount,
335
+ },
336
+ };
337
+ });
338
+
339
+ // Sort by lastRunAt descending (most recent first), fallback to name
340
+ scenariosWithMetadata.sort((a, b) => {
341
+ const aTime = a._metadata?.lastRunAt
342
+ ? new Date(a._metadata.lastRunAt).getTime()
343
+ : 0;
344
+ const bTime = b._metadata?.lastRunAt
345
+ ? new Date(b._metadata.lastRunAt).getTime()
346
+ : 0;
347
+ if (aTime !== bTime) return bTime - aTime;
348
+ return a.name.localeCompare(b.name);
349
+ });
350
+
351
+ res.json({ scenarios: scenariosWithMetadata });
352
+ } catch (error) {
353
+ next(error);
354
+ }
355
+ });
356
+
357
+ /**
358
+ * PUT /api/config
359
+ * Replace entire config file
360
+ */
361
+ app.put("/api/config", async (req, res, next) => {
362
+ try {
363
+ const newConfig = req.body;
364
+
365
+ // Validate structure
366
+ if (!newConfig.scenarios || !Array.isArray(newConfig.scenarios)) {
367
+ return res
368
+ .status(400)
369
+ .json({ error: 'Config must have a "scenarios" array' });
370
+ }
371
+
372
+ for (const scenario of newConfig.scenarios) {
373
+ if (!scenario.name) {
374
+ return res.status(400).json({
375
+ error: `Scenario missing "name": ${JSON.stringify(scenario)}`,
376
+ });
377
+ }
378
+ if (!scenario.key) {
379
+ return res
380
+ .status(400)
381
+ .json({ error: `Scenario "${scenario.name}" missing "key"` });
382
+ }
383
+ if (!scenario.url) {
384
+ return res
385
+ .status(400)
386
+ .json({ error: `Scenario "${scenario.name}" missing "url"` });
387
+ }
388
+ if (!scenario.steps || !Array.isArray(scenario.steps)) {
389
+ return res.status(400).json({
390
+ error: `Scenario "${scenario.name}" missing "steps" array`,
391
+ });
392
+ }
393
+ }
394
+
395
+ config.writeConfig(newConfig);
396
+
397
+ // Update settings if metadata changed
398
+ if (newConfig._metadata) {
399
+ const updatedSettings = {
400
+ ...settings,
401
+ projectName: newConfig._metadata.projectName || settings.projectName,
402
+ lastSyncedAt: new Date().toISOString(),
403
+ };
404
+ config.writeSettings(updatedSettings);
405
+ }
406
+
407
+ res.json({ ok: true, config: newConfig });
408
+ } catch (error) {
409
+ next(error);
410
+ }
411
+ });
412
+
413
+ // ===== PRIVACY ENDPOINTS =====
414
+
415
+ /**
416
+ * GET /api/privacy
417
+ * Returns current privacy configuration merged with defaults
418
+ */
419
+ app.get("/api/privacy", async (req, res, next) => {
420
+ try {
421
+ const docSyncConfig = config.configExists() ? config.readConfig() : {};
422
+ const privacyConfig = {
423
+ ...DEFAULT_PRIVACY_CONFIG,
424
+ ...(docSyncConfig.privacy || {}),
425
+ };
426
+ res.json(privacyConfig);
427
+ } catch (error) {
428
+ next(error);
429
+ }
430
+ });
431
+
432
+ /**
433
+ * PUT /api/privacy
434
+ * Update privacy configuration in docsync.config.json
435
+ */
436
+ app.put("/api/privacy", async (req, res, next) => {
437
+ try {
438
+ const newPrivacy = req.body;
439
+ const validation = validatePrivacyConfig(newPrivacy);
440
+ if (!validation.valid) {
441
+ return res.status(400).json({ error: validation.errors.join("; ") });
442
+ }
443
+
444
+ const docSyncConfig = config.configExists() ? config.readConfig() : { scenarios: [] };
445
+ docSyncConfig.privacy = newPrivacy;
446
+ config.writeConfig(docSyncConfig);
447
+
448
+ res.json({ ok: true, config: newPrivacy });
449
+ } catch (error) {
450
+ next(error);
451
+ }
452
+ });
453
+
454
+ // ===== STYLE ENDPOINTS =====
455
+
456
+ /**
457
+ * GET /api/style
458
+ * Returns current style configuration merged with defaults
459
+ */
460
+ app.get("/api/style", async (req, res, next) => {
461
+ try {
462
+ const docSyncConfig = config.configExists() ? config.readConfig() : {};
463
+ const styleConfig = {
464
+ ...DEFAULT_STYLE_CONFIG,
465
+ ...(docSyncConfig.style || {}),
466
+ };
467
+ res.json(styleConfig);
468
+ } catch (error) {
469
+ next(error);
470
+ }
471
+ });
472
+
473
+ /**
474
+ * PUT /api/style
475
+ * Update style configuration in docsync.config.json
476
+ */
477
+ app.put("/api/style", async (req, res, next) => {
478
+ try {
479
+ const newStyle = req.body;
480
+ const validation = validateStyleConfig(newStyle);
481
+ if (!validation.valid) {
482
+ return res.status(400).json({ error: validation.errors.join("; ") });
483
+ }
484
+
485
+ const docSyncConfig = config.configExists() ? config.readConfig() : { scenarios: [] };
486
+ docSyncConfig.style = newStyle;
487
+ config.writeConfig(docSyncConfig);
488
+
489
+ res.json({ ok: true, config: newStyle });
490
+ } catch (error) {
491
+ next(error);
492
+ }
493
+ });
494
+
495
+ /**
496
+ * POST /api/style/preview
497
+ * Generate a styled preview image
498
+ * Accepts { style, assetPath? }
499
+ * Returns { preview: "data:image/png;base64,..." }
500
+ */
501
+ app.post("/api/style/preview", async (req, res, next) => {
502
+ try {
503
+ if (!isStyleAvailable()) {
504
+ return res.status(400).json({ error: "Sharp is not available — style preview requires the sharp package" });
505
+ }
506
+
507
+ const { style, assetPath } = req.body;
508
+ if (!style) {
509
+ return res.status(400).json({ error: "style config is required" });
510
+ }
511
+
512
+ const validation = validateStyleConfig(style);
513
+ if (!validation.valid) {
514
+ return res.status(400).json({ error: validation.errors.join("; ") });
515
+ }
516
+
517
+ let inputBuffer;
518
+ if (assetPath) {
519
+ // Validate path safety
520
+ const resolvedPath = path.resolve(assetPath);
521
+ const outputBase = path.resolve(process.cwd(), ".reshot");
522
+ if (!isPathWithinBase(resolvedPath, outputBase) && !isPathWithinBase(resolvedPath, process.cwd())) {
523
+ return res.status(400).json({ error: "Asset path is outside project directory" });
524
+ }
525
+ if (!fs.existsSync(resolvedPath)) {
526
+ return res.status(404).json({ error: "Asset file not found" });
527
+ }
528
+ inputBuffer = fs.readFileSync(resolvedPath);
529
+ } else {
530
+ // Generate a placeholder gradient image (400x300)
531
+ const sharp = require("sharp");
532
+ inputBuffer = await sharp({
533
+ create: {
534
+ width: 400,
535
+ height: 300,
536
+ channels: 4,
537
+ background: { r: 99, g: 102, b: 241, alpha: 1 },
538
+ },
539
+ })
540
+ .png()
541
+ .toBuffer();
542
+ }
543
+
544
+ const styledBuffer = await applyStyle(inputBuffer, style, console, 1);
545
+ const base64 = styledBuffer.toString("base64");
546
+
547
+ res.json({ preview: `data:image/png;base64,${base64}` });
548
+ } catch (error) {
549
+ next(error);
550
+ }
551
+ });
552
+
553
+ /**
554
+ * GET /api/config/scenarios/:key
555
+ * Get a single scenario by key
556
+ */
557
+ app.get("/api/config/scenarios/:key", async (req, res, next) => {
558
+ try {
559
+ const docSyncConfig = config.readConfig();
560
+ const scenario = docSyncConfig.scenarios.find(
561
+ (s) => s.key === req.params.key,
562
+ );
563
+
564
+ if (!scenario) {
565
+ return res
566
+ .status(404)
567
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
568
+ }
569
+
570
+ res.json({ scenario });
571
+ } catch (error) {
572
+ next(error);
573
+ }
574
+ });
575
+
576
+ /**
577
+ * PATCH /api/config/scenarios/:key
578
+ * Partial update of a scenario
579
+ */
580
+ app.patch("/api/config/scenarios/:key", async (req, res, next) => {
581
+ try {
582
+ const docSyncConfig = config.readConfig();
583
+ const scenarioIndex = docSyncConfig.scenarios.findIndex(
584
+ (s) => s.key === req.params.key,
585
+ );
586
+
587
+ if (scenarioIndex === -1) {
588
+ return res
589
+ .status(404)
590
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
591
+ }
592
+
593
+ const allowedFields = [
594
+ "name",
595
+ "url",
596
+ "steps",
597
+ "contexts",
598
+ "matrix",
599
+ "metadata",
600
+ "output",
601
+ "locale",
602
+ "role",
603
+ "variant",
604
+ "variantPreset",
605
+ "privacy",
606
+ "style",
607
+ ];
608
+ const updates = req.body;
609
+
610
+ // Validate fields
611
+ for (const field of Object.keys(updates)) {
612
+ if (!allowedFields.includes(field)) {
613
+ return res
614
+ .status(400)
615
+ .json({ error: `Field "${field}" is not allowed for update` });
616
+ }
617
+ }
618
+
619
+ // Apply updates
620
+ docSyncConfig.scenarios[scenarioIndex] = {
621
+ ...docSyncConfig.scenarios[scenarioIndex],
622
+ ...updates,
623
+ };
624
+
625
+ // Validate updated scenario
626
+ const updated = docSyncConfig.scenarios[scenarioIndex];
627
+ if (
628
+ !updated.name ||
629
+ !updated.key ||
630
+ !updated.url ||
631
+ !Array.isArray(updated.steps)
632
+ ) {
633
+ return res.status(400).json({ error: "Updated scenario is invalid" });
634
+ }
635
+
636
+ config.writeConfig(docSyncConfig);
637
+ res.json({ ok: true, scenario: updated });
638
+ } catch (error) {
639
+ next(error);
640
+ }
641
+ });
642
+
643
+ /**
644
+ * POST /api/config/scenarios
645
+ * Create a new scenario
646
+ */
647
+ app.post("/api/config/scenarios", async (req, res, next) => {
648
+ try {
649
+ const docSyncConfig = config.readConfig();
650
+ const newScenario = req.body;
651
+
652
+ // Validate required fields
653
+ if (!newScenario.name || !newScenario.key || !newScenario.url) {
654
+ return res
655
+ .status(400)
656
+ .json({ error: "Scenario must have name, key, and url" });
657
+ }
658
+
659
+ // Check for duplicate key
660
+ if (docSyncConfig.scenarios.find((s) => s.key === newScenario.key)) {
661
+ return res.status(409).json({
662
+ error: `Scenario with key "${newScenario.key}" already exists`,
663
+ });
664
+ }
665
+
666
+ // Ensure steps array
667
+ if (!Array.isArray(newScenario.steps)) {
668
+ newScenario.steps = [];
669
+ }
670
+
671
+ docSyncConfig.scenarios.push(newScenario);
672
+ config.writeConfig(docSyncConfig);
673
+
674
+ res.status(201).json({ ok: true, scenario: newScenario });
675
+ } catch (error) {
676
+ next(error);
677
+ }
678
+ });
679
+
680
+ /**
681
+ * DELETE /api/config/scenarios/:key
682
+ * Delete a scenario
683
+ */
684
+ app.delete("/api/config/scenarios/:key", async (req, res, next) => {
685
+ try {
686
+ const docSyncConfig = config.readConfig();
687
+ const scenarioIndex = docSyncConfig.scenarios.findIndex(
688
+ (s) => s.key === req.params.key,
689
+ );
690
+
691
+ if (scenarioIndex === -1) {
692
+ return res
693
+ .status(404)
694
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
695
+ }
696
+
697
+ docSyncConfig.scenarios.splice(scenarioIndex, 1);
698
+ config.writeConfig(docSyncConfig);
699
+
700
+ res.json({ ok: true });
701
+ } catch (error) {
702
+ next(error);
703
+ }
704
+ });
705
+
706
+ /**
707
+ * DELETE /api/config/scenarios
708
+ * Bulk delete all scenarios from config
709
+ */
710
+ app.delete("/api/config/scenarios", async (req, res, next) => {
711
+ try {
712
+ const docSyncConfig = config.readConfig();
713
+ const deletedCount = (docSyncConfig.scenarios || []).length;
714
+ docSyncConfig.scenarios = [];
715
+ config.writeConfig(docSyncConfig);
716
+
717
+ res.json({ ok: true, deleted: deletedCount });
718
+ } catch (error) {
719
+ next(error);
720
+ }
721
+ });
722
+
723
+ /**
724
+ * DELETE /api/assets
725
+ * Bulk delete all assets from output folder
726
+ */
727
+ app.delete("/api/assets", async (req, res, next) => {
728
+ try {
729
+ const outputDir = path.join(process.cwd(), ".reshot", "output");
730
+
731
+ if (!fs.existsSync(outputDir)) {
732
+ return res.json({ ok: true, deleted: 0 });
733
+ }
734
+
735
+ // Count files before deletion
736
+ let fileCount = 0;
737
+ function countFiles(dir) {
738
+ const items = fs.readdirSync(dir);
739
+ for (const item of items) {
740
+ const fullPath = path.join(dir, item);
741
+ const stat = fs.statSync(fullPath);
742
+ if (stat.isDirectory()) {
743
+ countFiles(fullPath);
744
+ } else {
745
+ fileCount++;
746
+ }
747
+ }
748
+ }
749
+ countFiles(outputDir);
750
+
751
+ // Remove all contents of output directory
752
+ fs.emptyDirSync(outputDir);
753
+
754
+ res.json({ ok: true, deleted: fileCount });
755
+ } catch (error) {
756
+ next(error);
757
+ }
758
+ });
759
+
760
+ /**
761
+ * POST /api/assets/bulk-delete
762
+ * Delete assets for specific scenarios
763
+ */
764
+ app.post("/api/assets/bulk-delete", async (req, res, next) => {
765
+ try {
766
+ const { scenarioKeys } = req.body;
767
+
768
+ if (
769
+ !scenarioKeys ||
770
+ !Array.isArray(scenarioKeys) ||
771
+ scenarioKeys.length === 0
772
+ ) {
773
+ return res
774
+ .status(400)
775
+ .json({ error: "scenarioKeys array is required" });
776
+ }
777
+
778
+ // Validate all scenario keys before processing
779
+ for (const key of scenarioKeys) {
780
+ if (!isValidPathSegment(key)) {
781
+ return res
782
+ .status(400)
783
+ .json({ error: `Invalid scenario key: ${key}` });
784
+ }
785
+ }
786
+
787
+ const outputDir = path.join(process.cwd(), ".reshot", "output");
788
+
789
+ if (!fs.existsSync(outputDir)) {
790
+ return res.json({ ok: true, deletedScenarios: 0, deletedFiles: 0 });
791
+ }
792
+
793
+ let deletedScenarios = 0;
794
+ let deletedFiles = 0;
795
+
796
+ for (const scenarioKey of scenarioKeys) {
797
+ const scenarioDir = path.join(outputDir, scenarioKey);
798
+
799
+ // Verify the path is within the output directory
800
+ if (!isPathWithinBase(scenarioDir, outputDir)) {
801
+ continue; // Skip paths that escape the output directory
802
+ }
803
+
804
+ if (fs.existsSync(scenarioDir)) {
805
+ // Count files in this scenario directory
806
+ function countFilesInDir(dir) {
807
+ let count = 0;
808
+ const items = fs.readdirSync(dir);
809
+ for (const item of items) {
810
+ const fullPath = path.join(dir, item);
811
+ const stat = fs.statSync(fullPath);
812
+ if (stat.isDirectory()) {
813
+ count += countFilesInDir(fullPath);
814
+ } else {
815
+ count++;
816
+ }
817
+ }
818
+ return count;
819
+ }
820
+
821
+ deletedFiles += countFilesInDir(scenarioDir);
822
+
823
+ // Remove the scenario directory
824
+ fs.removeSync(scenarioDir);
825
+ deletedScenarios++;
826
+ }
827
+ }
828
+
829
+ res.json({ ok: true, deletedScenarios, deletedFiles });
830
+ } catch (error) {
831
+ next(error);
832
+ }
833
+ });
834
+
835
+ // ===== STEPS ENDPOINTS =====
836
+
837
+ /**
838
+ * POST /api/config/scenarios/:key/steps
839
+ * Add a step to a scenario
840
+ */
841
+ app.post("/api/config/scenarios/:key/steps", async (req, res, next) => {
842
+ try {
843
+ const docSyncConfig = config.readConfig();
844
+ const scenarioIndex = docSyncConfig.scenarios.findIndex(
845
+ (s) => s.key === req.params.key,
846
+ );
847
+
848
+ if (scenarioIndex === -1) {
849
+ return res
850
+ .status(404)
851
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
852
+ }
853
+
854
+ const newStep = req.body;
855
+ const scenario = docSyncConfig.scenarios[scenarioIndex];
856
+
857
+ if (!Array.isArray(scenario.steps)) {
858
+ scenario.steps = [];
859
+ }
860
+
861
+ scenario.steps.push(newStep);
862
+ config.writeConfig(docSyncConfig);
863
+
864
+ res
865
+ .status(201)
866
+ .json({ ok: true, step: newStep, index: scenario.steps.length - 1 });
867
+ } catch (error) {
868
+ next(error);
869
+ }
870
+ });
871
+
872
+ /**
873
+ * PATCH /api/config/scenarios/:key/steps/:index
874
+ * Update a step by index
875
+ */
876
+ app.patch(
877
+ "/api/config/scenarios/:key/steps/:index",
878
+ async (req, res, next) => {
879
+ try {
880
+ const docSyncConfig = config.readConfig();
881
+ const scenarioIndex = docSyncConfig.scenarios.findIndex(
882
+ (s) => s.key === req.params.key,
883
+ );
884
+
885
+ if (scenarioIndex === -1) {
886
+ return res
887
+ .status(404)
888
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
889
+ }
890
+
891
+ const stepIndex = parseInt(req.params.index, 10);
892
+ const scenario = docSyncConfig.scenarios[scenarioIndex];
893
+
894
+ if (
895
+ !Array.isArray(scenario.steps) ||
896
+ stepIndex < 0 ||
897
+ stepIndex >= scenario.steps.length
898
+ ) {
899
+ return res
900
+ .status(404)
901
+ .json({ error: `Step at index ${stepIndex} not found` });
902
+ }
903
+
904
+ scenario.steps[stepIndex] = {
905
+ ...scenario.steps[stepIndex],
906
+ ...req.body,
907
+ };
908
+
909
+ config.writeConfig(docSyncConfig);
910
+ res.json({ ok: true, step: scenario.steps[stepIndex] });
911
+ } catch (error) {
912
+ next(error);
913
+ }
914
+ },
915
+ );
916
+
917
+ /**
918
+ * DELETE /api/config/scenarios/:key/steps/:index
919
+ * Delete a step by index
920
+ */
921
+ app.delete(
922
+ "/api/config/scenarios/:key/steps/:index",
923
+ async (req, res, next) => {
924
+ try {
925
+ const docSyncConfig = config.readConfig();
926
+ const scenarioIndex = docSyncConfig.scenarios.findIndex(
927
+ (s) => s.key === req.params.key,
928
+ );
929
+
930
+ if (scenarioIndex === -1) {
931
+ return res
932
+ .status(404)
933
+ .json({ error: `Scenario with key "${req.params.key}" not found` });
934
+ }
935
+
936
+ const stepIndex = parseInt(req.params.index, 10);
937
+ const scenario = docSyncConfig.scenarios[scenarioIndex];
938
+
939
+ if (
940
+ !Array.isArray(scenario.steps) ||
941
+ stepIndex < 0 ||
942
+ stepIndex >= scenario.steps.length
943
+ ) {
944
+ return res
945
+ .status(404)
946
+ .json({ error: `Step at index ${stepIndex} not found` });
947
+ }
948
+
949
+ scenario.steps.splice(stepIndex, 1);
950
+ config.writeConfig(docSyncConfig);
951
+
952
+ res.json({ ok: true });
953
+ } catch (error) {
954
+ next(error);
955
+ }
956
+ },
957
+ );
958
+
959
+ // ===== STORAGE CONFIGURATION ENDPOINTS =====
960
+
961
+ /**
962
+ * GET /api/config/storage
963
+ * Returns current storage configuration
964
+ */
965
+ app.get("/api/config/storage", async (req, res, next) => {
966
+ try {
967
+ let docSyncConfig = null;
968
+ try {
969
+ docSyncConfig = config.readConfig();
970
+ } catch (error) {
971
+ // No config
972
+ }
973
+
974
+ const storageConfig = docSyncConfig?.storage || { type: "reshot" };
975
+
976
+ res.json({
977
+ storage: storageConfig,
978
+ mode: storageConfig.type === "reshot" ? "platform" : "byos",
979
+ });
980
+ } catch (error) {
981
+ next(error);
982
+ }
983
+ });
984
+
985
+ /**
986
+ * PUT /api/config/storage
987
+ * Update storage configuration
988
+ */
989
+ app.put("/api/config/storage", async (req, res, next) => {
990
+ try {
991
+ const { storage } = req.body;
992
+
993
+ if (!storage || !storage.type) {
994
+ return res.status(400).json({ error: "storage.type is required" });
995
+ }
996
+
997
+ const validTypes = ["reshot", "s3", "r2", "local"];
998
+ if (!validTypes.includes(storage.type)) {
999
+ return res.status(400).json({
1000
+ error: `Invalid storage type. Must be one of: ${validTypes.join(
1001
+ ", ",
1002
+ )}`,
1003
+ });
1004
+ }
1005
+
1006
+ // Read current config
1007
+ let docSyncConfig = {};
1008
+ try {
1009
+ docSyncConfig = config.readConfig();
1010
+ } catch (error) {
1011
+ // Start with empty config
1012
+ docSyncConfig = {
1013
+ baseUrl: "http://localhost:3000",
1014
+ assetDir: ".reshot/output",
1015
+ viewport: { width: 1280, height: 720 },
1016
+ scenarios: [],
1017
+ };
1018
+ }
1019
+
1020
+ // Update storage config
1021
+ docSyncConfig.storage = storage;
1022
+ config.writeConfig(docSyncConfig);
1023
+
1024
+ res.json({
1025
+ ok: true,
1026
+ storage,
1027
+ mode: storage.type === "reshot" ? "platform" : "byos",
1028
+ });
1029
+ } catch (error) {
1030
+ next(error);
1031
+ }
1032
+ });
1033
+
1034
+ /**
1035
+ * GET /api/config/env-check
1036
+ * Check which environment variables are set (without revealing values)
1037
+ */
1038
+ app.get("/api/config/env-check", async (req, res, next) => {
1039
+ try {
1040
+ const envVars = [
1041
+ "AWS_ACCESS_KEY_ID",
1042
+ "AWS_SECRET_ACCESS_KEY",
1043
+ "AWS_REGION",
1044
+ "R2_ACCESS_KEY_ID",
1045
+ "R2_SECRET_ACCESS_KEY",
1046
+ "CLOUDFLARE_ACCOUNT_ID",
1047
+ "RESHOT_API_KEY",
1048
+ "RESHOT_PROJECT_ID",
1049
+ ];
1050
+
1051
+ const envStatus = {};
1052
+ for (const key of envVars) {
1053
+ envStatus[key] = !!process.env[key];
1054
+ }
1055
+
1056
+ res.json({ envStatus });
1057
+ } catch (error) {
1058
+ next(error);
1059
+ }
1060
+ });
1061
+
1062
+ // ===== SETTINGS ENDPOINTS =====
1063
+
1064
+ /**
1065
+ * GET /api/settings
1066
+ * Returns sanitized settings (no secrets)
1067
+ */
1068
+ app.get("/api/settings", async (req, res, next) => {
1069
+ try {
1070
+ let currentSettings = null;
1071
+ try {
1072
+ currentSettings = config.readSettings();
1073
+ } catch (error) {
1074
+ // Settings don't exist - return degraded mode
1075
+ }
1076
+
1077
+ const sanitized = {
1078
+ isAuthenticated: !!(
1079
+ currentSettings?.apiKey && currentSettings?.projectId
1080
+ ),
1081
+ projectId: currentSettings?.projectId || null,
1082
+ projectName: currentSettings?.projectName || null,
1083
+ // workspace can be stored as either workspaceName (string) or workspace.name (object)
1084
+ workspaceName:
1085
+ currentSettings?.workspaceName ||
1086
+ currentSettings?.workspace?.name ||
1087
+ null,
1088
+ platformUrl: currentSettings?.platformUrl || null,
1089
+ linkedAt: currentSettings?.linkedAt || null,
1090
+ user: currentSettings?.user || null,
1091
+ lastSyncedAt: currentSettings?.lastSyncedAt || null,
1092
+ lastPublishedCommitHash:
1093
+ currentSettings?.lastPublishedCommitHash || null,
1094
+ features: currentSettings?._metadata?.features || null,
1095
+ };
1096
+
1097
+ res.json({ settings: sanitized });
1098
+ } catch (error) {
1099
+ next(error);
1100
+ }
1101
+ });
1102
+
1103
+ /**
1104
+ * POST /api/settings/init
1105
+ * Initialize project by fetching config from platform
1106
+ */
1107
+ app.post("/api/settings/init", async (req, res, next) => {
1108
+ try {
1109
+ const { projectId, overwrite = false } = req.body;
1110
+
1111
+ if (!projectId) {
1112
+ return res.status(400).json({ error: "projectId is required" });
1113
+ }
1114
+
1115
+ // Try to get settings to find apiKey
1116
+ let currentSettings = null;
1117
+ try {
1118
+ currentSettings = config.readSettings();
1119
+ } catch (error) {
1120
+ return res.status(400).json({
1121
+ error:
1122
+ "No CLI settings found. Run `reshot auth` first to authenticate.",
1123
+ });
1124
+ }
1125
+
1126
+ const { apiKey } = currentSettings;
1127
+ if (!apiKey) {
1128
+ return res.status(400).json({
1129
+ error: "Missing API key in settings. Run `reshot auth` again.",
1130
+ });
1131
+ }
1132
+
1133
+ // Use shared initializeProject helper
1134
+ const blueprint = await config.initializeProject(projectId, apiKey, {
1135
+ overwrite,
1136
+ });
1137
+
1138
+ // Read updated settings
1139
+ const updatedSettings = config.readSettings();
1140
+ const sanitized = {
1141
+ isAuthenticated: true,
1142
+ projectId: updatedSettings.projectId,
1143
+ projectName: updatedSettings.projectName || null,
1144
+ workspaceName: updatedSettings.workspaceName || null,
1145
+ lastSyncedAt: updatedSettings.lastSyncedAt || null,
1146
+ features: blueprint._metadata?.features || null,
1147
+ };
1148
+
1149
+ res.json({ ok: true, config: blueprint, settings: sanitized });
1150
+ } catch (error) {
1151
+ next(error);
1152
+ }
1153
+ });
1154
+
1155
+ // ===== WORKSPACE ENDPOINTS =====
1156
+
1157
+ /**
1158
+ * GET /api/workspace
1159
+ * Get current workspace with resolved scenarios
1160
+ */
1161
+ app.get("/api/workspace", async (req, res, next) => {
1162
+ try {
1163
+ const workspace = config.getWorkspaceWithScenarios();
1164
+ res.json({ workspace });
1165
+ } catch (error) {
1166
+ next(error);
1167
+ }
1168
+ });
1169
+
1170
+ /**
1171
+ * POST /api/workspace
1172
+ * Create or update workspace
1173
+ */
1174
+ app.post("/api/workspace", async (req, res, next) => {
1175
+ try {
1176
+ const { name, description, variants } = req.body;
1177
+
1178
+ let workspace = config.readWorkspace();
1179
+ if (workspace) {
1180
+ // Update existing
1181
+ workspace.name = name || workspace.name;
1182
+ workspace.description =
1183
+ description !== undefined ? description : workspace.description;
1184
+ if (variants) {
1185
+ workspace.variants = variants;
1186
+ }
1187
+ config.writeWorkspace(workspace);
1188
+ } else {
1189
+ // Create new
1190
+ workspace = config.createWorkspace({ name, description, variants });
1191
+ }
1192
+
1193
+ res.json({ ok: true, workspace: config.getWorkspaceWithScenarios() });
1194
+ } catch (error) {
1195
+ next(error);
1196
+ }
1197
+ });
1198
+
1199
+ /**
1200
+ * PUT /api/workspace/variants
1201
+ * Update workspace variant dimensions
1202
+ */
1203
+ app.put("/api/workspace/variants", async (req, res, next) => {
1204
+ try {
1205
+ const { dimensions, presets } = req.body;
1206
+
1207
+ let workspace = config.readWorkspace();
1208
+ if (!workspace) {
1209
+ workspace = config.createWorkspace();
1210
+ }
1211
+
1212
+ workspace.variants = {
1213
+ dimensions: dimensions || workspace.variants?.dimensions || {},
1214
+ presets: presets || workspace.variants?.presets || {},
1215
+ };
1216
+ config.writeWorkspace(workspace);
1217
+
1218
+ res.json({ ok: true, workspace: config.getWorkspaceWithScenarios() });
1219
+ } catch (error) {
1220
+ next(error);
1221
+ }
1222
+ });
1223
+
1224
+ /**
1225
+ * POST /api/workspace/scenarios
1226
+ * Add scenario(s) to workspace
1227
+ */
1228
+ app.post("/api/workspace/scenarios", async (req, res, next) => {
1229
+ try {
1230
+ const { scenarioKeys } = req.body;
1231
+
1232
+ if (!scenarioKeys || !Array.isArray(scenarioKeys)) {
1233
+ return res
1234
+ .status(400)
1235
+ .json({ error: "scenarioKeys array is required" });
1236
+ }
1237
+
1238
+ for (const key of scenarioKeys) {
1239
+ config.addScenarioToWorkspace(key);
1240
+ }
1241
+
1242
+ res.json({ ok: true, workspace: config.getWorkspaceWithScenarios() });
1243
+ } catch (error) {
1244
+ next(error);
1245
+ }
1246
+ });
1247
+
1248
+ /**
1249
+ * DELETE /api/workspace/scenarios/:key
1250
+ * Remove scenario from workspace
1251
+ */
1252
+ app.delete("/api/workspace/scenarios/:key", async (req, res, next) => {
1253
+ try {
1254
+ const { key } = req.params;
1255
+ config.removeScenarioFromWorkspace(key);
1256
+ res.json({ ok: true, workspace: config.getWorkspaceWithScenarios() });
1257
+ } catch (error) {
1258
+ next(error);
1259
+ }
1260
+ });
1261
+
1262
+ // ===== COMMIT ENDPOINTS =====
1263
+
1264
+ /**
1265
+ * POST /api/commit
1266
+ * Create a commit from selected workspace scenarios and publish to platform
1267
+ */
1268
+ app.post("/api/commit", async (req, res, next) => {
1269
+ try {
1270
+ const { message, scenarioKeys, includeAllVariants } = req.body;
1271
+
1272
+ if (!message || !message.trim()) {
1273
+ return res.status(400).json({ error: "Commit message is required" });
1274
+ }
1275
+
1276
+ // Get settings for API access
1277
+ const settings = config.readSettings();
1278
+ if (!settings?.apiKey || !settings?.projectId) {
1279
+ return res.status(400).json({
1280
+ error: "Not authenticated. Please connect to platform first.",
1281
+ });
1282
+ }
1283
+
1284
+ // Get workspace
1285
+ const workspace = config.getWorkspaceWithScenarios();
1286
+ if (!workspace) {
1287
+ return res.status(400).json({ error: "No workspace found" });
1288
+ }
1289
+
1290
+ // Determine which scenarios to include (use resolvedScenarios which have full scenario objects)
1291
+ let targetScenarios = workspace.resolvedScenarios || [];
1292
+ if (scenarioKeys && scenarioKeys.length > 0) {
1293
+ targetScenarios = targetScenarios.filter((s) =>
1294
+ scenarioKeys.includes(s.key),
1295
+ );
1296
+ }
1297
+
1298
+ if (targetScenarios.length === 0) {
1299
+ return res
1300
+ .status(400)
1301
+ .json({ error: "No scenarios selected for commit" });
1302
+ }
1303
+
1304
+ // Find captured assets for these scenarios
1305
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
1306
+ if (!fs.existsSync(outputBaseDir)) {
1307
+ return res.status(400).json({ error: "No captured assets found" });
1308
+ }
1309
+
1310
+ const assetFiles = findAssetFiles(outputBaseDir);
1311
+ const groups = groupAssetsByScenario(assetFiles, outputBaseDir);
1312
+
1313
+ // Filter to only selected scenarios (targetScenarios is now array of scenario objects)
1314
+ const selectedGroups = groups.filter((g) =>
1315
+ targetScenarios.some((s) => s.key === g.scenarioKey),
1316
+ );
1317
+
1318
+ if (selectedGroups.length === 0) {
1319
+ return res.status(400).json({
1320
+ error: "No captured assets found for selected scenarios",
1321
+ });
1322
+ }
1323
+
1324
+ // Prepare metadata for sync API
1325
+ const assets = [];
1326
+ const files = [];
1327
+
1328
+ for (const group of selectedGroups) {
1329
+ for (const asset of group.assets) {
1330
+ const filename = path.basename(asset.path);
1331
+ const format = path.extname(filename).slice(1).toLowerCase();
1332
+
1333
+ assets.push({
1334
+ scenarioKey: group.scenarioKey,
1335
+ scenarioName: targetScenarios.find(
1336
+ (s) => s.key === group.scenarioKey,
1337
+ )?.name,
1338
+ variationSlug: group.variationSlug,
1339
+ captureKey: asset.step || "capture",
1340
+ filename,
1341
+ format,
1342
+ });
1343
+
1344
+ files.push({
1345
+ path: asset.path,
1346
+ filename,
1347
+ });
1348
+ }
1349
+ }
1350
+
1351
+ // Get git info if available
1352
+ let gitInfo = {};
1353
+ try {
1354
+ const { execSync } = require("child_process");
1355
+ const commitHash = execSync("git rev-parse HEAD", {
1356
+ encoding: "utf-8",
1357
+ }).trim();
1358
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1359
+ encoding: "utf-8",
1360
+ }).trim();
1361
+ gitInfo = { commitHash, branch };
1362
+ } catch {
1363
+ // Not in a git repo
1364
+ }
1365
+
1366
+ // Build FormData for sync API
1367
+ const FormData = (await import("form-data")).default;
1368
+ const formData = new FormData();
1369
+
1370
+ // Add metadata
1371
+ const metadata = {
1372
+ projectId: settings.projectId,
1373
+ syncMode: "incremental",
1374
+ commit: {
1375
+ message: message.trim(),
1376
+ scenarioKeys: targetScenarios.map((s) => s.key),
1377
+ },
1378
+ assets,
1379
+ git: {
1380
+ ...gitInfo,
1381
+ commitMessage: message.trim(),
1382
+ },
1383
+ cli: {
1384
+ version: require("../../package.json").version,
1385
+ syncTimestamp: new Date().toISOString(),
1386
+ },
1387
+ };
1388
+
1389
+ formData.append("metadata", JSON.stringify(metadata));
1390
+
1391
+ // Add files - use key format expected by sync API: scenarioKey/variationSlug/filename
1392
+ for (let i = 0; i < files.length; i++) {
1393
+ const file = files[i];
1394
+ const asset = assets[i];
1395
+ const fileKey = `${asset.scenarioKey}/${asset.variationSlug}/${asset.filename}`;
1396
+ formData.append(fileKey, fs.createReadStream(file.path), file.filename);
1397
+ }
1398
+
1399
+ // Send to platform sync API
1400
+ const platformUrl = getPlatformUrl(settings);
1401
+ const axios = (await import("axios")).default;
1402
+
1403
+ let response;
1404
+ try {
1405
+ response = await axios.post(`${platformUrl}/api/v1/sync`, formData, {
1406
+ headers: {
1407
+ ...formData.getHeaders(),
1408
+ "X-API-Key": settings.apiKey,
1409
+ },
1410
+ maxContentLength: Infinity,
1411
+ maxBodyLength: Infinity,
1412
+ });
1413
+ } catch (axiosError) {
1414
+ // Check if this is an auth error
1415
+ const authHandled = handleApiError(axiosError, res);
1416
+ if (authHandled) return authHandled;
1417
+
1418
+ // Otherwise, throw for generic handling
1419
+ const errorMsg = axiosError.response?.data?.error || axiosError.message;
1420
+ throw new Error(`Platform sync failed: ${errorMsg}`);
1421
+ }
1422
+
1423
+ if (!response.data?.ok) {
1424
+ return res.status(500).json({
1425
+ error: response.data?.error || "Failed to sync to platform",
1426
+ });
1427
+ }
1428
+
1429
+ // Record commit in workspace
1430
+ const commitRecord = {
1431
+ id: response.data.commitId || `local-${Date.now()}`,
1432
+ message: message.trim(),
1433
+ scenarioKeys: targetScenarios.map((s) => s.key),
1434
+ assetCount: assets.length,
1435
+ createdAt: new Date().toISOString(),
1436
+ syncedAt: new Date().toISOString(),
1437
+ platformCommitId: response.data.commitId,
1438
+ };
1439
+
1440
+ let workspaceData = config.readWorkspace();
1441
+ if (!workspaceData) {
1442
+ workspaceData = config.createWorkspace();
1443
+ }
1444
+ if (!workspaceData.commits) {
1445
+ workspaceData.commits = [];
1446
+ }
1447
+ if (!Array.isArray(workspaceData.commits)) {
1448
+ workspaceData.commits = [];
1449
+ }
1450
+ workspaceData.commits.push(commitRecord);
1451
+ config.writeWorkspace(workspaceData);
1452
+
1453
+ res.json({
1454
+ ok: true,
1455
+ commit: commitRecord,
1456
+ syncResult: {
1457
+ processed: response.data.processed,
1458
+ errorCount: response.data.errorCount,
1459
+ commitId: response.data.commitId,
1460
+ changelogDraftId: response.data.changelogDraftId,
1461
+ },
1462
+ });
1463
+ } catch (error) {
1464
+ console.error("Commit error:", error);
1465
+ next(error);
1466
+ }
1467
+ });
1468
+
1469
+ /**
1470
+ * GET /api/commits
1471
+ * Get commit history from workspace
1472
+ */
1473
+ app.get("/api/commits", async (req, res, next) => {
1474
+ try {
1475
+ const workspace = config.readWorkspace();
1476
+ const commits = workspace?.commits || [];
1477
+ res.json({ commits: commits.reverse() }); // Most recent first
1478
+ } catch (error) {
1479
+ next(error);
1480
+ }
1481
+ });
1482
+
1483
+ /**
1484
+ * GET /api/status
1485
+ * Aggregator endpoint for Dashboard - combines settings, config, jobs, assets, and remote status
1486
+ */
1487
+ app.get("/api/status", async (req, res, next) => {
1488
+ try {
1489
+ const uiExecutor = require("./ui-executor");
1490
+
1491
+ // 1. Settings summary
1492
+ let currentSettings = null;
1493
+ try {
1494
+ currentSettings = config.readSettings();
1495
+ } catch (error) {
1496
+ // Settings don't exist
1497
+ }
1498
+
1499
+ const settings = {
1500
+ isAuthenticated: !!(
1501
+ currentSettings?.apiKey && currentSettings?.projectId
1502
+ ),
1503
+ projectId: currentSettings?.projectId || null,
1504
+ projectName: currentSettings?.projectName || null,
1505
+ workspaceName:
1506
+ currentSettings?.workspaceName ||
1507
+ currentSettings?.workspace?.name ||
1508
+ null,
1509
+ linkedAt: currentSettings?.linkedAt || null,
1510
+ user: currentSettings?.user || null,
1511
+ lastSyncedAt: currentSettings?.lastSyncedAt || null,
1512
+ lastPublishedCommitHash:
1513
+ currentSettings?.lastPublishedCommitHash || null,
1514
+ features: currentSettings?._metadata?.features || null,
1515
+ };
1516
+
1517
+ // 2. Config summary
1518
+ let docSyncConfig = null;
1519
+ let configError = null;
1520
+ if (config.configExists()) {
1521
+ try {
1522
+ docSyncConfig = config.readConfig();
1523
+ } catch (error) {
1524
+ configError = error.message;
1525
+ }
1526
+ }
1527
+
1528
+ const configStatus = {
1529
+ hasConfig: docSyncConfig !== null,
1530
+ configError,
1531
+ scenarioCount: docSyncConfig?.scenarios?.length || 0,
1532
+ totalSteps:
1533
+ docSyncConfig?.scenarios?.reduce(
1534
+ (sum, s) => sum + (s.steps?.length || 0),
1535
+ 0,
1536
+ ) || 0,
1537
+ lastSyncedAt: settings.lastSyncedAt,
1538
+ lastPublishedCommitHash: settings.lastPublishedCommitHash,
1539
+ };
1540
+
1541
+ // 3. Job summary (clean up stuck jobs first)
1542
+ uiExecutor.cleanupStuckJobs();
1543
+ const jobs = uiExecutor.getAllJobs(10); // Last 10 jobs
1544
+
1545
+ // 4. Local assets summary
1546
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
1547
+ let localAssets = {
1548
+ totalFiles: 0,
1549
+ totalSize: 0,
1550
+ groups: [],
1551
+ };
1552
+
1553
+ if (fs.existsSync(outputBaseDir)) {
1554
+ try {
1555
+ const assetFiles = findAssetFiles(outputBaseDir);
1556
+ const groups = groupAssetsByScenario(assetFiles, outputBaseDir);
1557
+ localAssets = {
1558
+ totalFiles: assetFiles.length,
1559
+ totalSize: assetFiles.reduce((sum, file) => {
1560
+ try {
1561
+ return sum + fs.statSync(file).size;
1562
+ } catch {
1563
+ return sum;
1564
+ }
1565
+ }, 0),
1566
+ groups: groups.map((g) => ({
1567
+ scenarioKey: g.scenarioKey,
1568
+ variationSlug: g.variationSlug,
1569
+ assetCount: g.assets.length,
1570
+ })),
1571
+ };
1572
+ } catch (error) {
1573
+ // Ignore asset enumeration errors
1574
+ }
1575
+ }
1576
+
1577
+ // 5. Remote summary (best-effort, don't fail if platform is unreachable)
1578
+ let remote = {
1579
+ visualsCount: 0,
1580
+ reviewQueueCount: 0,
1581
+ error: null,
1582
+ };
1583
+
1584
+ if (currentSettings?.projectId && currentSettings?.apiKey) {
1585
+ try {
1586
+ const [visuals, queue] = await Promise.all([
1587
+ apiClient
1588
+ .getVisuals(currentSettings.projectId, currentSettings.apiKey)
1589
+ .catch(() => ({ data: [] })),
1590
+ apiClient
1591
+ .getReviewQueue(currentSettings.projectId, currentSettings.apiKey)
1592
+ .catch(() => []),
1593
+ ]);
1594
+ remote.visualsCount = Array.isArray(visuals)
1595
+ ? visuals.length
1596
+ : visuals?.data?.length || 0;
1597
+ remote.reviewQueueCount = Array.isArray(queue) ? queue.length : 0;
1598
+ } catch (error) {
1599
+ remote.error = error.message;
1600
+ }
1601
+ }
1602
+
1603
+ res.json({
1604
+ settings,
1605
+ configStatus,
1606
+ jobs,
1607
+ localAssets,
1608
+ remote,
1609
+ });
1610
+ } catch (error) {
1611
+ next(error);
1612
+ }
1613
+ });
1614
+
1615
+ // ===== MODE & FEATURES ENDPOINTS =====
1616
+
1617
+ /**
1618
+ * GET /api/mode
1619
+ * Get current CLI mode (standalone vs connected) and available features
1620
+ */
1621
+ app.get("/api/mode", async (req, res, next) => {
1622
+ try {
1623
+ let currentSettings = null;
1624
+ try {
1625
+ currentSettings = config.readSettings();
1626
+ } catch (e) {
1627
+ // No settings
1628
+ }
1629
+
1630
+ const standalone = isStandaloneMode(currentSettings);
1631
+ const features = getAvailableFeatures(currentSettings);
1632
+
1633
+ res.json({
1634
+ mode: standalone ? "standalone" : "connected",
1635
+ features,
1636
+ settings: currentSettings
1637
+ ? {
1638
+ projectId: currentSettings.projectId,
1639
+ projectName: currentSettings.projectName,
1640
+ hasApiKey: !!currentSettings.apiKey,
1641
+ }
1642
+ : null,
1643
+ });
1644
+ } catch (error) {
1645
+ next(error);
1646
+ }
1647
+ });
1648
+
1649
+ // ===== VIEWPORT PRESETS ENDPOINTS =====
1650
+
1651
+ /**
1652
+ * GET /api/viewports
1653
+ * Get all viewport presets (built-in and custom)
1654
+ */
1655
+ app.get("/api/viewports", async (req, res, next) => {
1656
+ try {
1657
+ const builtIn = getAllViewportPresets();
1658
+ const byCategory = getViewportPresetsByCategory();
1659
+
1660
+ // Get custom presets from config
1661
+ let customPresets = {};
1662
+ if (config.configExists()) {
1663
+ try {
1664
+ const docSyncConfig = config.readConfig();
1665
+ customPresets = docSyncConfig.viewportPresets || {};
1666
+ } catch (e) {
1667
+ // Config parse error
1668
+ }
1669
+ }
1670
+
1671
+ res.json({
1672
+ builtIn,
1673
+ custom: customPresets,
1674
+ byCategory,
1675
+ all: { ...builtIn, ...customPresets },
1676
+ });
1677
+ } catch (error) {
1678
+ next(error);
1679
+ }
1680
+ });
1681
+
1682
+ /**
1683
+ * POST /api/viewports
1684
+ * Create or update a custom viewport preset
1685
+ */
1686
+ app.post("/api/viewports", async (req, res, next) => {
1687
+ try {
1688
+ const { key, name, width, height, deviceScaleFactor, description } =
1689
+ req.body;
1690
+
1691
+ if (!key || !width || !height) {
1692
+ return res.status(400).json({
1693
+ error: "key, width, and height are required",
1694
+ });
1695
+ }
1696
+
1697
+ const preset = {
1698
+ name: name || key,
1699
+ category: "custom",
1700
+ width: parseInt(width, 10),
1701
+ height: parseInt(height, 10),
1702
+ deviceScaleFactor: deviceScaleFactor
1703
+ ? parseFloat(deviceScaleFactor)
1704
+ : 2,
1705
+ description: description || `${width}×${height}`,
1706
+ };
1707
+
1708
+ const validation = validateViewport(preset);
1709
+ if (!validation.valid) {
1710
+ return res.status(400).json({ error: validation.error });
1711
+ }
1712
+
1713
+ // Save to config
1714
+ const savedConfig = config.saveViewportPreset(key, preset);
1715
+
1716
+ res.json({
1717
+ success: true,
1718
+ preset: { key, ...preset },
1719
+ viewportPresets: savedConfig.viewportPresets,
1720
+ });
1721
+ } catch (error) {
1722
+ next(error);
1723
+ }
1724
+ });
1725
+
1726
+ /**
1727
+ * DELETE /api/viewports/:key
1728
+ * Delete a custom viewport preset
1729
+ */
1730
+ app.delete("/api/viewports/:key", async (req, res, next) => {
1731
+ try {
1732
+ const { key } = req.params;
1733
+
1734
+ // Check if it's a built-in preset
1735
+ const builtIn = getAllViewportPresets();
1736
+ if (builtIn[key]) {
1737
+ return res.status(400).json({
1738
+ error: "Cannot delete built-in viewport presets",
1739
+ });
1740
+ }
1741
+
1742
+ config.deleteViewportPreset(key);
1743
+
1744
+ res.json({ success: true, deleted: key });
1745
+ } catch (error) {
1746
+ next(error);
1747
+ }
1748
+ });
1749
+
1750
+ // ===== CROP PRESETS ENDPOINTS =====
1751
+
1752
+ /**
1753
+ * GET /api/crops
1754
+ * Get all crop presets
1755
+ */
1756
+ app.get("/api/crops", async (req, res, next) => {
1757
+ try {
1758
+ const cropPresets = getAllCropPresets();
1759
+
1760
+ res.json({
1761
+ presets: cropPresets,
1762
+ });
1763
+ } catch (error) {
1764
+ next(error);
1765
+ }
1766
+ });
1767
+
1768
+ // ===== OUTPUT TEMPLATE ENDPOINTS =====
1769
+
1770
+ /**
1771
+ * GET /api/output-template
1772
+ * Get output template configuration and available presets
1773
+ */
1774
+ app.get("/api/output-template", async (req, res, next) => {
1775
+ try {
1776
+ const presets = getTemplatePresets();
1777
+
1778
+ let currentTemplate = null;
1779
+ let outputConfig = {};
1780
+ if (config.configExists()) {
1781
+ try {
1782
+ const docSyncConfig = config.readConfig();
1783
+ currentTemplate = docSyncConfig.output?.template || null;
1784
+ outputConfig = docSyncConfig.output || {};
1785
+ } catch (e) {
1786
+ // Config parse error
1787
+ }
1788
+ }
1789
+
1790
+ res.json({
1791
+ presets,
1792
+ currentTemplate,
1793
+ outputConfig,
1794
+ availableVariables: [
1795
+ { name: "scenario", description: "Scenario key" },
1796
+ { name: "scenarioName", description: "Human-readable scenario name" },
1797
+ { name: "name", description: "Asset/screenshot name" },
1798
+ { name: "assetName", description: "Alias for name" },
1799
+ { name: "step", description: "Step number (1-based)" },
1800
+ { name: "locale", description: "Current locale from variant" },
1801
+ { name: "role", description: "Current role from variant" },
1802
+ { name: "theme", description: "Current theme from variant" },
1803
+ { name: "variant", description: "Full variant slug" },
1804
+ { name: "timestamp", description: "ISO timestamp for run" },
1805
+ { name: "date", description: "Date portion (YYYY-MM-DD)" },
1806
+ { name: "time", description: "Time portion (HH-MM-SS)" },
1807
+ { name: "viewport", description: "Viewport preset or WxH" },
1808
+ { name: "viewportWidth", description: "Viewport width" },
1809
+ { name: "viewportHeight", description: "Viewport height" },
1810
+ { name: "ext", description: "File extension (default: png)" },
1811
+ ],
1812
+ });
1813
+ } catch (error) {
1814
+ next(error);
1815
+ }
1816
+ });
1817
+
1818
+ /**
1819
+ * PUT /api/output-template
1820
+ * Update output template configuration
1821
+ */
1822
+ app.put("/api/output-template", async (req, res, next) => {
1823
+ try {
1824
+ const { template, preset } = req.body;
1825
+
1826
+ let templateToUse = template;
1827
+
1828
+ // If preset name provided, get template from preset
1829
+ if (preset && !template) {
1830
+ const presets = getTemplatePresets();
1831
+ const presetConfig = presets.find((p) => p.name === preset);
1832
+ if (!presetConfig) {
1833
+ return res.status(400).json({ error: `Unknown preset: ${preset}` });
1834
+ }
1835
+ templateToUse = presetConfig.template;
1836
+ }
1837
+
1838
+ if (!templateToUse) {
1839
+ return res
1840
+ .status(400)
1841
+ .json({ error: "template or preset is required" });
1842
+ }
1843
+
1844
+ // Validate template
1845
+ const validation = validateTemplate(templateToUse);
1846
+ if (!validation.valid) {
1847
+ return res.status(400).json({ error: validation.error });
1848
+ }
1849
+
1850
+ // Update config
1851
+ const updatedConfig = config.updateOutputConfig({
1852
+ template: templateToUse,
1853
+ });
1854
+
1855
+ res.json({
1856
+ success: true,
1857
+ template: templateToUse,
1858
+ variables: parseTemplateVariables(templateToUse),
1859
+ warning: validation.warning || null,
1860
+ });
1861
+ } catch (error) {
1862
+ next(error);
1863
+ }
1864
+ });
1865
+
1866
+ /**
1867
+ * POST /api/output-template/validate
1868
+ * Validate an output template without saving
1869
+ */
1870
+ app.post("/api/output-template/validate", async (req, res, next) => {
1871
+ try {
1872
+ const { template } = req.body;
1873
+
1874
+ if (!template) {
1875
+ return res.status(400).json({ error: "template is required" });
1876
+ }
1877
+
1878
+ const validation = validateTemplate(template);
1879
+
1880
+ res.json({
1881
+ valid: validation.valid,
1882
+ error: validation.error || null,
1883
+ warning: validation.warning || null,
1884
+ variables: validation.variables || parseTemplateVariables(template),
1885
+ });
1886
+ } catch (error) {
1887
+ next(error);
1888
+ }
1889
+ });
1890
+
1891
+ // ===== OUTPUT/ASSETS ENDPOINTS =====
1892
+
1893
+ /**
1894
+ * GET /api/output
1895
+ * List all generated assets grouped by scenario and variation
1896
+ * Query params:
1897
+ * - allVersions=true: Include all timestamped versions (not just latest)
1898
+ * - latestJobOnly=true: Only include the most recent timestamp folder (for publish preview)
1899
+ */
1900
+ app.get("/api/output", async (req, res, next) => {
1901
+ try {
1902
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
1903
+ const includeAllVersions = req.query.allVersions === "true";
1904
+ const latestJobOnly = req.query.latestJobOnly === "true";
1905
+
1906
+ if (!fs.existsSync(outputBaseDir)) {
1907
+ return res.json({ groups: [], versions: [] });
1908
+ }
1909
+
1910
+ const assetFiles = findAssetFiles(outputBaseDir, undefined, {
1911
+ includeAllVersions,
1912
+ latestJobOnly,
1913
+ });
1914
+ const groups = groupAssetsByScenario(assetFiles, outputBaseDir);
1915
+
1916
+ // Also return available version timestamps per scenario
1917
+ const versions = getVersionsPerScenario(outputBaseDir);
1918
+
1919
+ res.json({ groups, versions });
1920
+ } catch (error) {
1921
+ next(error);
1922
+ }
1923
+ });
1924
+
1925
+ // ========================================
1926
+ // SPECIFIC ROUTES MUST COME BEFORE GENERIC
1927
+ // Express matches routes in order, so /versions and /version/:timestamp
1928
+ // must be defined BEFORE /:scenarioKey/:variationSlug
1929
+ // ========================================
1930
+
1931
+ /**
1932
+ * GET /api/output/:scenarioKey/versions
1933
+ * List all version timestamps for a specific scenario with asset counts
1934
+ * Also detects variant subfolders (e.g., light, dark) within each timestamp
1935
+ */
1936
+ app.get("/api/output/:scenarioKey/versions", async (req, res, next) => {
1937
+ try {
1938
+ const { scenarioKey } = req.params;
1939
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
1940
+ const scenarioDir = path.join(outputBaseDir, scenarioKey);
1941
+
1942
+ if (!fs.existsSync(scenarioDir)) {
1943
+ return res.json({ versions: [] });
1944
+ }
1945
+
1946
+ const isTimestamp = (name) =>
1947
+ /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(name);
1948
+ const extensions = [".png", ".gif", ".mp4", ".jpg", ".jpeg", ".webm"];
1949
+
1950
+ // Helper to count assets in a directory
1951
+ function countAssets(dir) {
1952
+ let count = 0;
1953
+ function walk(d) {
1954
+ try {
1955
+ const items = fs.readdirSync(d);
1956
+ for (const item of items) {
1957
+ const fullPath = path.join(d, item);
1958
+ const stat = fs.statSync(fullPath);
1959
+ if (stat.isDirectory()) {
1960
+ walk(fullPath);
1961
+ } else if (
1962
+ extensions.includes(path.extname(item).toLowerCase())
1963
+ ) {
1964
+ count++;
1965
+ }
1966
+ }
1967
+ } catch (e) {
1968
+ /* ignore */
1969
+ }
1970
+ }
1971
+ walk(dir);
1972
+ return count;
1973
+ }
1974
+
1975
+ // Helper to detect variant subfolders in a directory
1976
+ function detectVariants(dir) {
1977
+ try {
1978
+ const items = fs.readdirSync(dir);
1979
+ const variants = [];
1980
+ for (const item of items) {
1981
+ const fullPath = path.join(dir, item);
1982
+ const stat = fs.statSync(fullPath);
1983
+ if (stat.isDirectory()) {
1984
+ // Check if this folder contains assets (is a variant folder)
1985
+ const assetCount = countAssets(fullPath);
1986
+ if (assetCount > 0) {
1987
+ variants.push({
1988
+ name: item,
1989
+ assetCount,
1990
+ path: fullPath,
1991
+ });
1992
+ }
1993
+ }
1994
+ }
1995
+ return variants;
1996
+ } catch (e) {
1997
+ return [];
1998
+ }
1999
+ }
2000
+
2001
+ const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
2002
+ const fullPath = path.join(scenarioDir, item);
2003
+ try {
2004
+ return fs.statSync(fullPath).isDirectory();
2005
+ } catch {
2006
+ return false;
2007
+ }
2008
+ });
2009
+
2010
+ // Get timestamp folders sorted newest first
2011
+ const timestampFolders = subFolders
2012
+ .filter((f) => isTimestamp(f))
2013
+ .sort()
2014
+ .reverse();
2015
+
2016
+ // Also include 'latest' and 'default' folders if they exist and have assets
2017
+ const specialFolders = subFolders.filter(
2018
+ (f) => f === "latest" || f === "default",
2019
+ );
2020
+
2021
+ const versions = [];
2022
+
2023
+ // Add timestamp versions first (newest first)
2024
+ timestampFolders.forEach((ts, index) => {
2025
+ const tsPath = path.join(scenarioDir, ts);
2026
+ const totalAssetCount = countAssets(tsPath);
2027
+
2028
+ // Detect variants in this timestamp folder
2029
+ const variants = detectVariants(tsPath);
2030
+
2031
+ // Parse timestamp to human-readable format
2032
+ const parts = ts.match(
2033
+ /(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})/,
2034
+ );
2035
+ let label = ts;
2036
+ let isoDate = ts;
2037
+ if (parts) {
2038
+ const date = new Date(
2039
+ `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}`,
2040
+ );
2041
+ // Validate the date is valid before using
2042
+ if (!isNaN(date.getTime())) {
2043
+ label = date.toLocaleString();
2044
+ isoDate = date.toISOString();
2045
+ }
2046
+ }
2047
+
2048
+ // Read manifest for privacy/style metadata
2049
+ let manifestMeta = {};
2050
+ try {
2051
+ const manifestPath = path.join(tsPath, "manifest.json");
2052
+ if (fs.existsSync(manifestPath)) {
2053
+ const manifest = fs.readJSONSync(manifestPath);
2054
+ if (manifest.privacy) manifestMeta.privacy = manifest.privacy;
2055
+ if (manifest.style) manifestMeta.style = manifest.style;
2056
+ }
2057
+ } catch (_e) { /* ignore */ }
2058
+
2059
+ versions.push({
2060
+ timestamp: ts,
2061
+ label,
2062
+ date: isoDate,
2063
+ assetCount: totalAssetCount,
2064
+ isLatest: index === 0,
2065
+ variants: variants.map((v) => ({
2066
+ name: v.name,
2067
+ assetCount: v.assetCount,
2068
+ })),
2069
+ hasVariants: variants.length > 0,
2070
+ ...manifestMeta,
2071
+ });
2072
+ });
2073
+
2074
+ // Add special folders at the end (but only if they have assets and not duplicating timestamp versions)
2075
+ specialFolders.forEach((folder) => {
2076
+ const folderPath = path.join(scenarioDir, folder);
2077
+ const assetCount = countAssets(folderPath);
2078
+ const variants = detectVariants(folderPath);
2079
+ if (assetCount > 0) {
2080
+ // Only add if we don't already have timestamp versions (to avoid redundancy)
2081
+ const label = folder === "latest" ? "Latest" : "Default";
2082
+ versions.push({
2083
+ timestamp: folder,
2084
+ label,
2085
+ date: new Date().toISOString(),
2086
+ assetCount,
2087
+ isLatest: folder === "latest" && versions.length === 0,
2088
+ variants: variants.map((v) => ({
2089
+ name: v.name,
2090
+ assetCount: v.assetCount,
2091
+ })),
2092
+ hasVariants: variants.length > 0,
2093
+ });
2094
+ }
2095
+ });
2096
+
2097
+ res.json({ versions });
2098
+ } catch (error) {
2099
+ next(error);
2100
+ }
2101
+ });
2102
+
2103
+ /**
2104
+ * GET /api/output/:scenarioKey/version/:timestamp
2105
+ * Get assets for a specific version timestamp
2106
+ */
2107
+ app.get(
2108
+ "/api/output/:scenarioKey/version/:timestamp",
2109
+ async (req, res, next) => {
2110
+ try {
2111
+ const { scenarioKey, timestamp } = req.params;
2112
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2113
+ const versionDir = path.join(outputBaseDir, scenarioKey, timestamp);
2114
+
2115
+ if (!fs.existsSync(versionDir)) {
2116
+ return res.json({ assets: [] });
2117
+ }
2118
+
2119
+ const extensions = [".png", ".gif", ".mp4", ".jpg", ".jpeg", ".webm"];
2120
+ const assets = [];
2121
+ const diffManifests = []; // Collect all diff manifests found
2122
+
2123
+ function collectAssets(dir, baseRelativePath = "") {
2124
+ try {
2125
+ const items = fs.readdirSync(dir);
2126
+ for (const item of items) {
2127
+ // Skip diffs folder - these are generated by diff engine
2128
+ if (item === "diffs") continue;
2129
+
2130
+ const fullPath = path.join(dir, item);
2131
+ const stat = fs.statSync(fullPath);
2132
+ const relativePath = baseRelativePath
2133
+ ? `${baseRelativePath}/${item}`
2134
+ : item;
2135
+
2136
+ if (stat.isDirectory()) {
2137
+ collectAssets(fullPath, relativePath);
2138
+ } else {
2139
+ // Check for diff manifest files
2140
+ if (item === "diff-manifest.json") {
2141
+ try {
2142
+ const manifest = fs.readJSONSync(fullPath);
2143
+ diffManifests.push({ path: baseRelativePath, manifest });
2144
+ } catch (e) {
2145
+ // Ignore read errors
2146
+ }
2147
+ continue;
2148
+ }
2149
+
2150
+ const ext = path.extname(item).toLowerCase();
2151
+ if (extensions.includes(ext)) {
2152
+ const relativeFromOutput = path.relative(
2153
+ outputBaseDir,
2154
+ fullPath,
2155
+ );
2156
+ // Use relative path without extension as key to match manifest
2157
+ const assetKey = relativePath.replace(/\.[^/.]+$/, "");
2158
+ const captureKey = path.basename(item, ext);
2159
+ const isSentinel = baseRelativePath.includes("sentinels");
2160
+ assets.push({
2161
+ assetKey, // Full path key for matching manifest
2162
+ captureKey, // Just filename for display
2163
+ path: fullPath,
2164
+ relativePath: relativeFromOutput,
2165
+ filename: item,
2166
+ size: stat.size,
2167
+ mtime: stat.mtime.toISOString(),
2168
+ url: `/assets/${relativeFromOutput.replace(/\\/g, "/")}`,
2169
+ isSentinel,
2170
+ });
2171
+ }
2172
+ }
2173
+ }
2174
+ } catch (e) {
2175
+ // Ignore errors
2176
+ }
2177
+ }
2178
+
2179
+ collectAssets(versionDir);
2180
+
2181
+ // Merge all diff manifests and enrich assets
2182
+ let mergedSummary = null;
2183
+ let comparedAgainst = null;
2184
+
2185
+ for (const { manifest } of diffManifests) {
2186
+ if (manifest.comparedAgainst) {
2187
+ comparedAgainst = manifest.comparedAgainst;
2188
+ }
2189
+
2190
+ // Merge summary - normalize field names for UI
2191
+ if (manifest.summary) {
2192
+ if (!mergedSummary) {
2193
+ mergedSummary = {
2194
+ total: manifest.summary.total || 0,
2195
+ new: manifest.summary.newAssets || manifest.summary.new || 0,
2196
+ changed: manifest.summary.changed || 0,
2197
+ unchanged: manifest.summary.unchanged || 0,
2198
+ };
2199
+ } else {
2200
+ mergedSummary.total += manifest.summary.total || 0;
2201
+ mergedSummary.new +=
2202
+ manifest.summary.newAssets || manifest.summary.new || 0;
2203
+ mergedSummary.changed += manifest.summary.changed || 0;
2204
+ mergedSummary.unchanged += manifest.summary.unchanged || 0;
2205
+ }
2206
+ }
2207
+
2208
+ // Enrich assets with diff data
2209
+ if (manifest.assets) {
2210
+ for (const asset of assets) {
2211
+ const diffData = manifest.assets[asset.assetKey];
2212
+ if (diffData) {
2213
+ asset.diff = {
2214
+ status: diffData.status,
2215
+ hasDiff: diffData.hasDiff,
2216
+ score: diffData.score,
2217
+ reason: diffData.reason,
2218
+ diffUrl: diffData.diffPath
2219
+ ? `/assets/${scenarioKey}/${timestamp}/${diffData.diffPath}`
2220
+ : null,
2221
+ };
2222
+ }
2223
+ }
2224
+ }
2225
+ }
2226
+
2227
+ res.json({
2228
+ assets,
2229
+ timestamp,
2230
+ diffManifest: mergedSummary
2231
+ ? {
2232
+ comparedAgainst,
2233
+ summary: mergedSummary,
2234
+ }
2235
+ : null,
2236
+ });
2237
+ } catch (error) {
2238
+ next(error);
2239
+ }
2240
+ },
2241
+ );
2242
+
2243
+ /**
2244
+ * GET /api/output/:scenarioKey/version/:timestamp/variant/:variant
2245
+ * Get assets for a specific variant within a version timestamp
2246
+ */
2247
+ app.get(
2248
+ "/api/output/:scenarioKey/version/:timestamp/variant/:variant",
2249
+ async (req, res, next) => {
2250
+ try {
2251
+ const { scenarioKey, timestamp, variant } = req.params;
2252
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2253
+ const variantDir = path.join(outputBaseDir, scenarioKey, timestamp, variant);
2254
+
2255
+ if (!fs.existsSync(variantDir)) {
2256
+ return res.status(404).json({ error: "Variant not found", assets: [] });
2257
+ }
2258
+
2259
+ const extensions = [".png", ".gif", ".mp4", ".jpg", ".jpeg", ".webm"];
2260
+ const assets = [];
2261
+
2262
+ function collectAssets(dir, baseRelativePath = "") {
2263
+ try {
2264
+ const items = fs.readdirSync(dir);
2265
+ for (const item of items) {
2266
+ // Skip diffs folder
2267
+ if (item === "diffs") continue;
2268
+
2269
+ const fullPath = path.join(dir, item);
2270
+ const stat = fs.statSync(fullPath);
2271
+ const relativePath = baseRelativePath
2272
+ ? `${baseRelativePath}/${item}`
2273
+ : item;
2274
+
2275
+ if (stat.isDirectory()) {
2276
+ collectAssets(fullPath, relativePath);
2277
+ } else {
2278
+ const ext = path.extname(item).toLowerCase();
2279
+ if (extensions.includes(ext)) {
2280
+ const relativeFromOutput = path.relative(outputBaseDir, fullPath);
2281
+ const captureKey = path.basename(item, ext);
2282
+ const isSentinel = relativePath.includes("sentinels");
2283
+ assets.push({
2284
+ captureKey,
2285
+ path: fullPath,
2286
+ relativePath: relativeFromOutput,
2287
+ filename: item,
2288
+ size: stat.size,
2289
+ mtime: stat.mtime.toISOString(),
2290
+ url: `/assets/${relativeFromOutput.replace(/\\/g, "/")}`,
2291
+ isSentinel,
2292
+ });
2293
+ }
2294
+ }
2295
+ }
2296
+ } catch (e) {
2297
+ // Ignore errors
2298
+ }
2299
+ }
2300
+
2301
+ collectAssets(variantDir);
2302
+
2303
+ res.json({
2304
+ assets,
2305
+ timestamp,
2306
+ variant,
2307
+ });
2308
+ } catch (error) {
2309
+ next(error);
2310
+ }
2311
+ },
2312
+ );
2313
+
2314
+ /**
2315
+ * GET /api/output/:scenarioKey/version/:timestamp/diff-manifest
2316
+ * Get the diff manifest for a specific version
2317
+ */
2318
+ app.get(
2319
+ "/api/output/:scenarioKey/version/:timestamp/diff-manifest",
2320
+ async (req, res, next) => {
2321
+ try {
2322
+ const { scenarioKey, timestamp } = req.params;
2323
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2324
+ const manifestPath = path.join(
2325
+ outputBaseDir,
2326
+ scenarioKey,
2327
+ timestamp,
2328
+ "diff-manifest.json",
2329
+ );
2330
+
2331
+ if (!fs.existsSync(manifestPath)) {
2332
+ return res.json({
2333
+ manifest: null,
2334
+ message: "No diff manifest found for this version",
2335
+ });
2336
+ }
2337
+
2338
+ const manifest = fs.readJSONSync(manifestPath);
2339
+ res.json({ manifest });
2340
+ } catch (error) {
2341
+ next(error);
2342
+ }
2343
+ },
2344
+ );
2345
+
2346
+ /**
2347
+ * GET /api/output/:scenarioKey/:variationSlug/sentinels
2348
+ * List sentinel frames for a specific scenario/variation (for video bundles)
2349
+ *
2350
+ * Sentinels are captured at each step during video recording for diffing.
2351
+ * Structure: .reshot/output/<scenarioKey>/<timestamp>/<variationSlug>/sentinels/
2352
+ */
2353
+ app.get(
2354
+ "/api/output/:scenarioKey/:variationSlug/sentinels",
2355
+ async (req, res, next) => {
2356
+ try {
2357
+ const { scenarioKey, variationSlug } = req.params;
2358
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2359
+ const scenarioDir = path.join(outputBaseDir, scenarioKey);
2360
+
2361
+ if (!fs.existsSync(scenarioDir)) {
2362
+ return res.json({ files: [], sentinelsManifest: null });
2363
+ }
2364
+
2365
+ // Find sentinels directory in timestamped folders
2366
+ let sentinelsDir = null;
2367
+ let sentinelsManifest = null;
2368
+
2369
+ // Check for sentinels in timestamped folders (most recent first)
2370
+ const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
2371
+ const fullPath = path.join(scenarioDir, item);
2372
+ try {
2373
+ return fs.statSync(fullPath).isDirectory();
2374
+ } catch {
2375
+ return false;
2376
+ }
2377
+ });
2378
+
2379
+ const timestampedFolders = subFolders
2380
+ .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(f))
2381
+ .sort()
2382
+ .reverse();
2383
+
2384
+ // Look in timestamped folders for variation/sentinels
2385
+ for (const tsFolder of timestampedFolders) {
2386
+ const possiblePaths = [
2387
+ path.join(scenarioDir, tsFolder, variationSlug, "sentinels"),
2388
+ path.join(scenarioDir, tsFolder, "sentinels"), // If no variation nesting
2389
+ ];
2390
+
2391
+ for (const sentinelPath of possiblePaths) {
2392
+ if (
2393
+ fs.existsSync(sentinelPath) &&
2394
+ fs.statSync(sentinelPath).isDirectory()
2395
+ ) {
2396
+ sentinelsDir = sentinelPath;
2397
+
2398
+ // Check for sentinels.json manifest
2399
+ const manifestPath = path.join(sentinelPath, "sentinels.json");
2400
+ if (fs.existsSync(manifestPath)) {
2401
+ try {
2402
+ sentinelsManifest = fs.readJSONSync(manifestPath);
2403
+ } catch (e) {
2404
+ // Ignore parse errors
2405
+ }
2406
+ }
2407
+ break;
2408
+ }
2409
+ }
2410
+ if (sentinelsDir) break;
2411
+ }
2412
+
2413
+ if (!sentinelsDir) {
2414
+ return res.json({ files: [], sentinelsManifest: null });
2415
+ }
2416
+
2417
+ // List PNG files in sentinels directory
2418
+ const files = fs
2419
+ .readdirSync(sentinelsDir)
2420
+ .filter((f) => f.endsWith(".png"))
2421
+ .sort((a, b) => {
2422
+ // Sort by step number if present (step-0, step-1, etc.)
2423
+ const numA = parseInt((a.match(/step-(\d+)/) || [])[1] || "0", 10);
2424
+ const numB = parseInt((b.match(/step-(\d+)/) || [])[1] || "0", 10);
2425
+ return numA - numB;
2426
+ });
2427
+
2428
+ // Calculate relative path for asset URLs
2429
+ const relativePath = path.relative(outputBaseDir, sentinelsDir);
2430
+
2431
+ res.json({
2432
+ files,
2433
+ sentinelsManifest,
2434
+ basePath: `/assets/${relativePath.replace(/\\/g, "/")}`,
2435
+ });
2436
+ } catch (error) {
2437
+ next(error);
2438
+ }
2439
+ },
2440
+ );
2441
+
2442
+ // ========================================
2443
+ // GENERIC ROUTE - must come AFTER specific routes
2444
+ // ========================================
2445
+
2446
+ /**
2447
+ * GET /api/output/:scenarioKey/:variationSlug
2448
+ * List assets for a specific scenario/variation
2449
+ *
2450
+ * Handles multiple directory structures:
2451
+ * - .reshot/output/<scenarioKey>/<variationSlug>/ (direct)
2452
+ * - .reshot/output/<scenarioKey>/latest/ (latest version)
2453
+ * - .reshot/output/<scenarioKey>/<timestamp>/ (timestamped)
2454
+ * - .reshot/output/<scenarioKey>/<timestamp>/<variationSlug>/ (variant in timestamp)
2455
+ */
2456
+ app.get("/api/output/:scenarioKey/:variationSlug", async (req, res, next) => {
2457
+ try {
2458
+ const { scenarioKey, variationSlug } = req.params;
2459
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2460
+ const scenarioDir = path.join(outputBaseDir, scenarioKey);
2461
+
2462
+ if (!fs.existsSync(scenarioDir)) {
2463
+ return res.json({ assets: [] });
2464
+ }
2465
+
2466
+ // Try to find the variation folder in order of preference
2467
+ let variationDir = null;
2468
+ const directPath = path.join(scenarioDir, variationSlug);
2469
+
2470
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isDirectory()) {
2471
+ variationDir = directPath;
2472
+ } else {
2473
+ // Look for the variation inside timestamped folders
2474
+ const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
2475
+ const fullPath = path.join(scenarioDir, item);
2476
+ try {
2477
+ return fs.statSync(fullPath).isDirectory();
2478
+ } catch {
2479
+ return false;
2480
+ }
2481
+ });
2482
+
2483
+ // Sort timestamped folders by date (most recent first)
2484
+ const timestampedFolders = subFolders
2485
+ .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(f))
2486
+ .sort()
2487
+ .reverse();
2488
+
2489
+ // Check timestamped folders for the variation
2490
+ for (const tsFolder of timestampedFolders) {
2491
+ const nestedPath = path.join(scenarioDir, tsFolder, variationSlug);
2492
+ if (
2493
+ fs.existsSync(nestedPath) &&
2494
+ fs.statSync(nestedPath).isDirectory()
2495
+ ) {
2496
+ variationDir = nestedPath;
2497
+ break;
2498
+ }
2499
+ }
2500
+
2501
+ // If still not found and looking for 'latest', try the most recent timestamp
2502
+ if (
2503
+ !variationDir &&
2504
+ variationSlug === "latest" &&
2505
+ timestampedFolders.length > 0
2506
+ ) {
2507
+ variationDir = path.join(scenarioDir, timestampedFolders[0]);
2508
+ }
2509
+ }
2510
+
2511
+ if (!variationDir || !fs.existsSync(variationDir)) {
2512
+ return res.json({ assets: [] });
2513
+ }
2514
+
2515
+ // Collect assets from the variation folder
2516
+ const extensions = [".png", ".gif", ".mp4", ".jpg", ".jpeg", ".webm"];
2517
+ const assets = [];
2518
+
2519
+ function collectAssets(dir, baseRelativePath = "") {
2520
+ try {
2521
+ const items = fs.readdirSync(dir);
2522
+ for (const item of items) {
2523
+ const fullPath = path.join(dir, item);
2524
+ const stat = fs.statSync(fullPath);
2525
+ const relativePath = baseRelativePath
2526
+ ? `${baseRelativePath}/${item}`
2527
+ : item;
2528
+
2529
+ if (stat.isDirectory()) {
2530
+ collectAssets(fullPath, relativePath);
2531
+ } else {
2532
+ const ext = path.extname(item).toLowerCase();
2533
+ if (extensions.includes(ext)) {
2534
+ const relativeFromOutput = path.relative(
2535
+ outputBaseDir,
2536
+ fullPath,
2537
+ );
2538
+ // Extract captureKey from filename (remove extension)
2539
+ const captureKey = path.basename(item, ext);
2540
+ assets.push({
2541
+ captureKey,
2542
+ path: fullPath,
2543
+ relativePath: relativeFromOutput,
2544
+ filename: item,
2545
+ size: stat.size,
2546
+ mtime: stat.mtime.toISOString(),
2547
+ url: `/assets/${relativeFromOutput.replace(/\\/g, "/")}`,
2548
+ });
2549
+ }
2550
+ }
2551
+ }
2552
+ } catch (e) {
2553
+ // Ignore errors
2554
+ }
2555
+ }
2556
+
2557
+ collectAssets(variationDir);
2558
+
2559
+ res.json({ assets });
2560
+ } catch (error) {
2561
+ next(error);
2562
+ }
2563
+ });
2564
+
2565
+ // Serve static assets from output directory
2566
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
2567
+ if (fs.existsSync(outputBaseDir)) {
2568
+ app.use("/assets", express.static(outputBaseDir));
2569
+ }
2570
+
2571
+ // ===== AUTH ENDPOINTS =====
2572
+
2573
+ /**
2574
+ * GET /api/auth/verify
2575
+ * Verify current API key is still valid and refresh project info from platform
2576
+ */
2577
+ app.get("/api/auth/verify", async (req, res) => {
2578
+ try {
2579
+ let currentSettings = null;
2580
+ try {
2581
+ currentSettings = config.readSettings();
2582
+ } catch (error) {
2583
+ return res
2584
+ .status(401)
2585
+ .json(
2586
+ config.createAuthErrorResponse(
2587
+ "No CLI settings found. Run authentication.",
2588
+ ),
2589
+ );
2590
+ }
2591
+
2592
+ if (!currentSettings?.apiKey) {
2593
+ return res
2594
+ .status(401)
2595
+ .json(
2596
+ config.createAuthErrorResponse(
2597
+ "No API key found. Please authenticate.",
2598
+ ),
2599
+ );
2600
+ }
2601
+
2602
+ const axios = require("axios");
2603
+ const platformUrl = getPlatformUrl(currentSettings);
2604
+
2605
+ try {
2606
+ // Verify API key with platform and get current project info
2607
+ const verifyRes = await axios.get(
2608
+ `${platformUrl}/api/auth/cli/verify`,
2609
+ {
2610
+ headers: {
2611
+ Authorization: `Bearer ${currentSettings.apiKey}`,
2612
+ },
2613
+ timeout: 10000,
2614
+ },
2615
+ );
2616
+
2617
+ const payload = verifyRes.data?.data || verifyRes.data;
2618
+
2619
+ // Update settings with latest project info from platform if available
2620
+ if (payload?.project) {
2621
+ const updatedSettings = {
2622
+ ...currentSettings,
2623
+ projectId: payload.project.id || currentSettings.projectId,
2624
+ projectName: payload.project.name || currentSettings.projectName,
2625
+ workspaceName:
2626
+ payload.project.workspace?.name ||
2627
+ currentSettings.workspaceName ||
2628
+ currentSettings.workspace?.name,
2629
+ };
2630
+ config.writeSettings(updatedSettings);
2631
+ currentSettings = updatedSettings;
2632
+ }
2633
+
2634
+ res.json({
2635
+ ok: true,
2636
+ valid: true,
2637
+ projectId: currentSettings.projectId,
2638
+ projectName: currentSettings.projectName,
2639
+ workspaceName:
2640
+ currentSettings.workspaceName || currentSettings.workspace?.name,
2641
+ user: currentSettings.user,
2642
+ });
2643
+ } catch (verifyError) {
2644
+ // Check if this is specifically an auth error
2645
+ if (config.isAuthError(verifyError)) {
2646
+ return res
2647
+ .status(401)
2648
+ .json(
2649
+ config.createAuthErrorResponse(
2650
+ "API key is invalid or expired. Please re-authenticate.",
2651
+ ),
2652
+ );
2653
+ }
2654
+ // Network or other error - don't assume auth failure
2655
+ console.warn(
2656
+ "API key verification error (may be network):",
2657
+ verifyError.message,
2658
+ );
2659
+ res.json({
2660
+ ok: true,
2661
+ valid: "unknown",
2662
+ warning: "Could not verify API key - platform may be unreachable",
2663
+ projectId: currentSettings.projectId,
2664
+ projectName: currentSettings.projectName,
2665
+ });
2666
+ }
2667
+ } catch (error) {
2668
+ console.error("Auth verify error:", error);
2669
+ res.status(500).json({ error: error.message });
2670
+ }
2671
+ });
2672
+
2673
+ /**
2674
+ * POST /api/auth/refresh
2675
+ * Refresh connection info from platform (project name, workspace, etc.)
2676
+ */
2677
+ app.post("/api/auth/refresh", async (req, res) => {
2678
+ try {
2679
+ let currentSettings = null;
2680
+ try {
2681
+ currentSettings = config.readSettings();
2682
+ } catch (error) {
2683
+ return res
2684
+ .status(401)
2685
+ .json({ error: "No CLI settings found. Please authenticate first." });
2686
+ }
2687
+
2688
+ if (!currentSettings?.apiKey || !currentSettings?.projectId) {
2689
+ return res
2690
+ .status(401)
2691
+ .json({ error: "Not authenticated. Please connect first." });
2692
+ }
2693
+
2694
+ const axios = require("axios");
2695
+ const platformUrl = getPlatformUrl(currentSettings);
2696
+
2697
+ try {
2698
+ // Fetch project details from platform
2699
+ const projectRes = await axios.get(
2700
+ `${platformUrl}/api/projects/${currentSettings.projectId}`,
2701
+ {
2702
+ headers: {
2703
+ "X-API-Key": currentSettings.apiKey,
2704
+ },
2705
+ timeout: 10000,
2706
+ },
2707
+ );
2708
+
2709
+ const project = projectRes.data?.data || projectRes.data;
2710
+
2711
+ if (!project) {
2712
+ return res
2713
+ .status(404)
2714
+ .json({ error: "Project not found on platform" });
2715
+ }
2716
+
2717
+ // Update local settings with latest from platform
2718
+ const updatedSettings = {
2719
+ ...currentSettings,
2720
+ projectName: project.name || currentSettings.projectName,
2721
+ workspaceName:
2722
+ project.workspace?.name || currentSettings.workspaceName,
2723
+ workspace: project.workspace || currentSettings.workspace,
2724
+ };
2725
+ config.writeSettings(updatedSettings);
2726
+
2727
+ res.json({
2728
+ ok: true,
2729
+ projectId: updatedSettings.projectId,
2730
+ projectName: updatedSettings.projectName,
2731
+ workspaceName:
2732
+ updatedSettings.workspaceName || updatedSettings.workspace?.name,
2733
+ user: updatedSettings.user,
2734
+ linkedAt: updatedSettings.linkedAt,
2735
+ });
2736
+ } catch (fetchError) {
2737
+ console.error("Failed to fetch project info:", fetchError.message);
2738
+
2739
+ if (config.isAuthError(fetchError)) {
2740
+ return res.status(401).json({
2741
+ error: "API key is invalid. Please re-authenticate.",
2742
+ authRequired: true,
2743
+ });
2744
+ }
2745
+
2746
+ // Return current settings even if refresh failed
2747
+ res.json({
2748
+ ok: false,
2749
+ warning: "Could not refresh from platform - showing cached data",
2750
+ projectId: currentSettings.projectId,
2751
+ projectName: currentSettings.projectName,
2752
+ workspaceName:
2753
+ currentSettings.workspaceName || currentSettings.workspace?.name,
2754
+ user: currentSettings.user,
2755
+ linkedAt: currentSettings.linkedAt,
2756
+ });
2757
+ }
2758
+ } catch (error) {
2759
+ console.error("Auth refresh error:", error);
2760
+ res.status(500).json({ error: error.message });
2761
+ }
2762
+ });
2763
+
2764
+ // Store for active auth sessions
2765
+ const activeAuthSessions = new Map();
2766
+
2767
+ /**
2768
+ * POST /api/auth/start
2769
+ * Initiates the browser-based authentication flow
2770
+ */
2771
+ app.post("/api/auth/start", async (req, res, next) => {
2772
+ try {
2773
+ const axios = require("axios");
2774
+ const pkg = require("../../package.json");
2775
+ const { getApiBaseUrl } = require("./api-client");
2776
+ const apiBaseUrl = getApiBaseUrl();
2777
+
2778
+ // Initiate auth session with platform
2779
+ // Use default callback port since we'll poll instead
2780
+ const initiateResponse = await axios.post(
2781
+ `${apiBaseUrl}/auth/cli/initiate`,
2782
+ {
2783
+ callbackPort: 3721, // Default port, we'll poll instead of callback
2784
+ clientVersion: pkg.version,
2785
+ },
2786
+ { headers: { "Content-Type": "application/json" } },
2787
+ );
2788
+
2789
+ const payload = initiateResponse.data?.data || initiateResponse.data;
2790
+ const { authUrl, authToken, expiresAt } = payload;
2791
+
2792
+ if (!authUrl || !authToken) {
2793
+ return res.status(500).json({
2794
+ error: "Authentication session did not return a URL or token",
2795
+ });
2796
+ }
2797
+
2798
+ // Store session for polling
2799
+ activeAuthSessions.set(authToken, {
2800
+ expiresAt,
2801
+ status: "pending",
2802
+ createdAt: new Date().toISOString(),
2803
+ });
2804
+
2805
+ // Clean up expired sessions
2806
+ const now = Date.now();
2807
+ for (const [token, session] of activeAuthSessions) {
2808
+ if (session.expiresAt && Date.parse(session.expiresAt) < now) {
2809
+ activeAuthSessions.delete(token);
2810
+ }
2811
+ }
2812
+
2813
+ res.json({
2814
+ ok: true,
2815
+ authUrl,
2816
+ authToken,
2817
+ expiresAt,
2818
+ });
2819
+ } catch (error) {
2820
+ console.error("Auth start error:", error.message);
2821
+ next(error);
2822
+ }
2823
+ });
2824
+
2825
+ /**
2826
+ * GET /api/auth/status
2827
+ * Poll for authentication status
2828
+ */
2829
+ app.get("/api/auth/status", async (req, res, next) => {
2830
+ try {
2831
+ const { token } = req.query;
2832
+
2833
+ if (!token) {
2834
+ return res.status(400).json({ error: "Auth token is required" });
2835
+ }
2836
+
2837
+ const axios = require("axios");
2838
+ const { getApiBaseUrl } = require("./api-client");
2839
+ const apiBaseUrl = getApiBaseUrl();
2840
+
2841
+ const statusResponse = await axios.get(`${apiBaseUrl}/auth/cli/status`, {
2842
+ params: { token },
2843
+ });
2844
+
2845
+ const payload = statusResponse.data?.data || statusResponse.data;
2846
+ const { status, project, user } = payload;
2847
+
2848
+ if (status === "completed" && project?.apiKey) {
2849
+ // Save settings
2850
+ const pkg = require("../../package.json");
2851
+ const { getApiBaseUrl } = require("./api-client");
2852
+ const apiBaseUrl = getApiBaseUrl();
2853
+ // Derive platformUrl from apiBaseUrl (remove /api suffix)
2854
+ const platformUrl =
2855
+ apiBaseUrl.replace(/\/api\/?$/, "") || "http://localhost:3000";
2856
+
2857
+ config.writeSettings({
2858
+ projectId: project.id,
2859
+ projectName: project.name,
2860
+ apiKey: project.apiKey,
2861
+ platformUrl: platformUrl,
2862
+ workspace: project.workspace || null,
2863
+ workspaceName: project.workspace?.name || null,
2864
+ linkedAt: new Date().toISOString(),
2865
+ cliVersion: pkg.version,
2866
+ user: user
2867
+ ? {
2868
+ id: user.id,
2869
+ email: user.email,
2870
+ fullName: user.fullName,
2871
+ }
2872
+ : null,
2873
+ settingsDir: config.SETTINGS_DIR,
2874
+ });
2875
+
2876
+ // Clean up session
2877
+ activeAuthSessions.delete(token);
2878
+
2879
+ res.json({
2880
+ ok: true,
2881
+ status: "completed",
2882
+ projectId: project.id,
2883
+ projectName: project.name,
2884
+ workspaceName: project.workspace?.name,
2885
+ });
2886
+ } else if (status === "expired") {
2887
+ activeAuthSessions.delete(token);
2888
+ res.json({
2889
+ ok: false,
2890
+ status: "expired",
2891
+ error: "Authentication token expired",
2892
+ });
2893
+ } else if (status === "invalid") {
2894
+ activeAuthSessions.delete(token);
2895
+ res.json({
2896
+ ok: false,
2897
+ status: "invalid",
2898
+ error: "Authentication session invalid",
2899
+ });
2900
+ } else {
2901
+ res.json({ ok: true, status: "pending" });
2902
+ }
2903
+ } catch (error) {
2904
+ console.error("Auth status error:", error.message);
2905
+ // If the token is invalid or expired on the platform side
2906
+ if (error.response?.status === 404 || error.response?.status === 400) {
2907
+ return res.json({
2908
+ ok: false,
2909
+ status: "expired",
2910
+ error: "Session not found or expired",
2911
+ });
2912
+ }
2913
+ next(error);
2914
+ }
2915
+ });
2916
+
2917
+ /**
2918
+ * POST /api/auth/open-browser
2919
+ * Opens the auth URL in the user's browser
2920
+ */
2921
+ app.post("/api/auth/open-browser", async (req, res, next) => {
2922
+ try {
2923
+ const { authUrl } = req.body;
2924
+
2925
+ if (!authUrl) {
2926
+ return res.status(400).json({ error: "authUrl is required" });
2927
+ }
2928
+
2929
+ // open is ESM-only, require it with default fallback
2930
+ const openModule = require("open");
2931
+ const open = openModule.default || openModule;
2932
+
2933
+ await open(authUrl, { wait: false });
2934
+
2935
+ res.json({ ok: true, message: "Browser opened" });
2936
+ } catch (error) {
2937
+ console.error("Failed to open browser:", error.message);
2938
+ res.json({
2939
+ ok: false,
2940
+ error: "Failed to open browser. Please copy the URL manually.",
2941
+ });
2942
+ }
2943
+ });
2944
+
2945
+ // ===== SYNC ENDPOINTS =====
2946
+
2947
+ /**
2948
+ * GET /api/sync/status
2949
+ * Get comprehensive sync status including local assets and platform state
2950
+ */
2951
+ app.get("/api/sync/status", async (req, res, next) => {
2952
+ try {
2953
+ let currentSettings = null;
2954
+ try {
2955
+ currentSettings = config.readSettings();
2956
+ } catch (error) {
2957
+ return res
2958
+ .status(400)
2959
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
2960
+ }
2961
+
2962
+ if (!currentSettings?.projectId || !currentSettings?.apiKey) {
2963
+ return res
2964
+ .status(400)
2965
+ .json({ error: "Missing projectId or apiKey in settings" });
2966
+ }
2967
+
2968
+ // Get local assets summary
2969
+ const localOutputDir = path.join(process.cwd(), ".reshot", "output");
2970
+ const localAssets = findAssetFiles(localOutputDir);
2971
+ const groupedLocal = groupAssetsByScenario(localAssets, localOutputDir);
2972
+
2973
+ // Get platform status
2974
+ let platformStatus = null;
2975
+ try {
2976
+ platformStatus = await apiClient.getSyncStatus(currentSettings.apiKey);
2977
+ } catch (error) {
2978
+ console.warn("Failed to fetch platform status:", error.message);
2979
+ }
2980
+
2981
+ // Build summary
2982
+ const localSummary = {
2983
+ totalAssets: localAssets.length,
2984
+ scenarios: groupedLocal.map((g) => ({
2985
+ key: g.scenarioKey,
2986
+ variations: g.variationSlug,
2987
+ assetCount: g.assets.length,
2988
+ })),
2989
+ scenarioCount: new Set(groupedLocal.map((g) => g.scenarioKey)).size,
2990
+ variationCount: new Set(groupedLocal.map((g) => g.variationSlug)).size,
2991
+ };
2992
+
2993
+ res.json({
2994
+ local: localSummary,
2995
+ platform: platformStatus,
2996
+ lastSyncedAt: currentSettings.lastSyncedAt || null,
2997
+ isAuthenticated: true,
2998
+ projectId: currentSettings.projectId,
2999
+ });
3000
+ } catch (error) {
3001
+ next(error);
3002
+ }
3003
+ });
3004
+
3005
+ /**
3006
+ * GET /api/sync/diff
3007
+ * Get differences between local and remote config
3008
+ */
3009
+ app.get("/api/sync/diff", async (req, res, next) => {
3010
+ try {
3011
+ let currentSettings = null;
3012
+ try {
3013
+ currentSettings = config.readSettings();
3014
+ } catch (error) {
3015
+ return res
3016
+ .status(400)
3017
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
3018
+ }
3019
+
3020
+ if (!currentSettings?.projectId || !currentSettings?.apiKey) {
3021
+ return res
3022
+ .status(400)
3023
+ .json({ error: "Missing projectId or apiKey in settings" });
3024
+ }
3025
+
3026
+ let localConfig = null;
3027
+ if (config.configExists()) {
3028
+ try {
3029
+ localConfig = config.readConfig();
3030
+ } catch (error) {
3031
+ // Local config invalid
3032
+ }
3033
+ }
3034
+
3035
+ let remoteConfig = null;
3036
+ try {
3037
+ remoteConfig = await apiClient.getProjectConfig(
3038
+ currentSettings.projectId,
3039
+ currentSettings.apiKey,
3040
+ );
3041
+ } catch (error) {
3042
+ // Remote fetch failed
3043
+ }
3044
+
3045
+ res.json({
3046
+ local: localConfig,
3047
+ remote: remoteConfig,
3048
+ });
3049
+ } catch (error) {
3050
+ next(error);
3051
+ }
3052
+ });
3053
+
3054
+ /**
3055
+ * POST /api/sync/pull
3056
+ * Pull config from platform and merge with local
3057
+ */
3058
+ app.post("/api/sync/pull", async (req, res, next) => {
3059
+ try {
3060
+ let currentSettings = null;
3061
+ try {
3062
+ currentSettings = config.readSettings();
3063
+ } catch (error) {
3064
+ return res
3065
+ .status(400)
3066
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
3067
+ }
3068
+
3069
+ if (!currentSettings?.projectId || !currentSettings?.apiKey) {
3070
+ return res
3071
+ .status(400)
3072
+ .json({ error: "Missing projectId or apiKey in settings" });
3073
+ }
3074
+
3075
+ const remoteConfig = await apiClient.getProjectConfig(
3076
+ currentSettings.projectId,
3077
+ currentSettings.apiKey,
3078
+ );
3079
+
3080
+ // Merge strategy: use remote as base, preserve local-only fields if they exist
3081
+ let localConfig = null;
3082
+ if (config.configExists()) {
3083
+ try {
3084
+ localConfig = config.readConfig();
3085
+ } catch (error) {
3086
+ // Local config invalid, use remote as-is
3087
+ }
3088
+ }
3089
+
3090
+ // For v1: simple merge - use remote config, but preserve _local metadata if present
3091
+ const mergedConfig = {
3092
+ ...remoteConfig,
3093
+ _metadata: {
3094
+ ...remoteConfig._metadata,
3095
+ ...(localConfig?._metadata || {}),
3096
+ lastSyncedAt: new Date().toISOString(),
3097
+ },
3098
+ };
3099
+
3100
+ // Preserve local-only scenario metadata if present
3101
+ if (localConfig?.scenarios) {
3102
+ const localScenarioMap = new Map(
3103
+ localConfig.scenarios.map((s) => [s.key, s]),
3104
+ );
3105
+ mergedConfig.scenarios = mergedConfig.scenarios.map(
3106
+ (remoteScenario) => {
3107
+ const localScenario = localScenarioMap.get(remoteScenario.key);
3108
+ if (localScenario?._local) {
3109
+ return {
3110
+ ...remoteScenario,
3111
+ _local: localScenario._local,
3112
+ };
3113
+ }
3114
+ return remoteScenario;
3115
+ },
3116
+ );
3117
+ }
3118
+
3119
+ config.writeConfig(mergedConfig);
3120
+
3121
+ // Update settings
3122
+ const updatedSettings = {
3123
+ ...currentSettings,
3124
+ lastSyncedAt: new Date().toISOString(),
3125
+ projectName:
3126
+ mergedConfig._metadata?.projectName || currentSettings.projectName,
3127
+ };
3128
+ config.writeSettings(updatedSettings);
3129
+
3130
+ // Save last remote config for diffing
3131
+ const lastRemotePath = path.join(
3132
+ process.cwd(),
3133
+ ".reshot",
3134
+ "last-remote-config.json",
3135
+ );
3136
+ fs.writeJSONSync(lastRemotePath, remoteConfig, { spaces: 2 });
3137
+
3138
+ res.json({ ok: true, config: mergedConfig });
3139
+ } catch (error) {
3140
+ next(error);
3141
+ }
3142
+ });
3143
+
3144
+ /**
3145
+ * POST /api/sync/push
3146
+ * Push local assets to platform (uploads to Supabase/storage and creates Visual records)
3147
+ */
3148
+ app.post("/api/sync/push", async (req, res, next) => {
3149
+ try {
3150
+ const { commitMessage, selectedAssets } = req.body;
3151
+
3152
+ // Check settings
3153
+ let currentSettings = null;
3154
+ try {
3155
+ currentSettings = config.readSettings();
3156
+ } catch (error) {
3157
+ return res
3158
+ .status(400)
3159
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
3160
+ }
3161
+
3162
+ if (!currentSettings?.projectId || !currentSettings?.apiKey) {
3163
+ return res
3164
+ .status(400)
3165
+ .json({ error: "Missing projectId or apiKey in settings" });
3166
+ }
3167
+
3168
+ const docSyncConfig = config.readConfig();
3169
+
3170
+ // Find all asset files in output directory
3171
+ const outputDir = path.join(process.cwd(), ".reshot", "output");
3172
+ let assetFiles = findAssetFiles(outputDir);
3173
+
3174
+ if (assetFiles.length === 0) {
3175
+ return res.json({
3176
+ ok: true,
3177
+ message: "No assets found to sync",
3178
+ assetsFound: 0,
3179
+ });
3180
+ }
3181
+
3182
+ // Filter by selected assets if provided
3183
+ if (
3184
+ selectedAssets &&
3185
+ Array.isArray(selectedAssets) &&
3186
+ selectedAssets.length > 0
3187
+ ) {
3188
+ const selectedKeys = new Set();
3189
+ for (const group of selectedAssets) {
3190
+ for (const asset of group.assets) {
3191
+ const key = `${group.scenarioKey}/${group.variationSlug}/${asset.filename}`;
3192
+ selectedKeys.add(key);
3193
+ }
3194
+ }
3195
+
3196
+ assetFiles = assetFiles.filter((filePath) => {
3197
+ const relativePath = path.relative(outputDir, filePath);
3198
+ const parts = relativePath.split(path.sep);
3199
+ if (parts.length >= 3) {
3200
+ const key = `${parts[0]}/${parts[1]}/${parts.slice(2).join("/")}`;
3201
+ return selectedKeys.has(key);
3202
+ }
3203
+ return false;
3204
+ });
3205
+ }
3206
+
3207
+ // Group assets by scenario and variation
3208
+ const groupedAssets = groupAssetsByScenario(assetFiles, outputDir);
3209
+
3210
+ // Build metadata for sync
3211
+ const syncAssets = [];
3212
+ const assetFilesMap = {};
3213
+
3214
+ for (const group of groupedAssets) {
3215
+ const scenario = docSyncConfig.scenarios?.find(
3216
+ (s) => s.key === group.scenarioKey,
3217
+ );
3218
+
3219
+ for (const asset of group.assets) {
3220
+ const ext = path.extname(asset.filename).slice(1).toLowerCase();
3221
+ const format = ["png", "jpg", "jpeg", "gif", "mp4", "webm"].includes(
3222
+ ext,
3223
+ )
3224
+ ? ext
3225
+ : "png";
3226
+
3227
+ syncAssets.push({
3228
+ scenarioKey: group.scenarioKey,
3229
+ scenarioName: scenario?.name || group.scenarioKey,
3230
+ variationSlug: group.variationSlug,
3231
+ captureKey: asset.captureKey,
3232
+ filename: asset.filename,
3233
+ format,
3234
+ });
3235
+
3236
+ const fileKey = `${group.scenarioKey}/${group.variationSlug}/${asset.filename}`;
3237
+ assetFilesMap[fileKey] = asset.path;
3238
+ }
3239
+ }
3240
+
3241
+ // Build variation context mapping from config
3242
+ const variationContext = {};
3243
+ const variantsConfig = docSyncConfig?.variants || {};
3244
+ const dimensions = variantsConfig.dimensions || {};
3245
+
3246
+ // Parse variation slugs into dimension values
3247
+ for (const group of groupedAssets) {
3248
+ const slug = group.variationSlug;
3249
+ if (!variationContext[slug] && slug !== "default") {
3250
+ // Try to parse slug like "en-admin-light"
3251
+ const parts = slug.split("-");
3252
+ const context = {};
3253
+
3254
+ // Map parts to known dimensions (order: locale, role, theme)
3255
+ const dimKeys = Object.keys(dimensions);
3256
+ for (let i = 0; i < parts.length && i < dimKeys.length; i++) {
3257
+ const dimKey = dimKeys[i];
3258
+ context[dimKey] = parts[i];
3259
+ }
3260
+
3261
+ variationContext[slug] = context;
3262
+ }
3263
+ }
3264
+
3265
+ // Build sync metadata
3266
+ // Get git info if available
3267
+ let gitInfo = {};
3268
+ try {
3269
+ const { execSync } = require("child_process");
3270
+ gitInfo.commitHash = execSync("git rev-parse HEAD", {
3271
+ encoding: "utf-8",
3272
+ }).trim();
3273
+ gitInfo.branch = execSync("git rev-parse --abbrev-ref HEAD", {
3274
+ encoding: "utf-8",
3275
+ }).trim();
3276
+ } catch (e) {
3277
+ // Git not available or not in a repo
3278
+ }
3279
+
3280
+ const metadata = {
3281
+ projectId: currentSettings.projectId,
3282
+ syncMode: "incremental",
3283
+ assets: syncAssets,
3284
+ variationContext,
3285
+ git: {
3286
+ ...gitInfo,
3287
+ commitMessage: commitMessage || gitInfo.commitMessage || undefined,
3288
+ },
3289
+ cli: {
3290
+ version: require("../../package.json").version,
3291
+ syncTimestamp: new Date().toISOString(),
3292
+ },
3293
+ };
3294
+
3295
+ // Execute sync
3296
+ let result;
3297
+ try {
3298
+ result = await apiClient.syncPushAssets(
3299
+ currentSettings.apiKey,
3300
+ metadata,
3301
+ assetFilesMap,
3302
+ (progress) => {
3303
+ console.log("Sync progress:", progress);
3304
+ },
3305
+ );
3306
+ } catch (syncError) {
3307
+ // Check if this is an auth error
3308
+ const authHandled = handleApiError(syncError, res);
3309
+ if (authHandled) return authHandled;
3310
+ throw syncError;
3311
+ }
3312
+
3313
+ // Update settings with last sync time
3314
+ const updatedSettings = {
3315
+ ...currentSettings,
3316
+ lastSyncedAt: new Date().toISOString(),
3317
+ };
3318
+ config.writeSettings(updatedSettings);
3319
+
3320
+ res.json({
3321
+ ok: true,
3322
+ message: `Successfully synced ${result.processed} assets to platform`,
3323
+ ...result,
3324
+ });
3325
+ } catch (error) {
3326
+ console.error("Sync push error:", error);
3327
+ // Final check for auth errors
3328
+ const authHandled = handleApiError(error, res);
3329
+ if (authHandled) return authHandled;
3330
+ next(error);
3331
+ }
3332
+ });
3333
+
3334
+ /**
3335
+ * PATCH /api/config/features
3336
+ * Update feature toggles in config metadata
3337
+ */
3338
+ app.patch("/api/config/features", async (req, res, next) => {
3339
+ try {
3340
+ const docSyncConfig = config.readConfig();
3341
+ const { features } = req.body;
3342
+
3343
+ if (!docSyncConfig._metadata) {
3344
+ docSyncConfig._metadata = {};
3345
+ }
3346
+
3347
+ docSyncConfig._metadata.features = {
3348
+ ...(docSyncConfig._metadata.features || {}),
3349
+ ...features,
3350
+ };
3351
+
3352
+ config.writeConfig(docSyncConfig);
3353
+
3354
+ res.json({ ok: true, features: docSyncConfig._metadata.features });
3355
+ } catch (error) {
3356
+ next(error);
3357
+ }
3358
+ });
3359
+
3360
+ /**
3361
+ * GET /api/remote/visuals
3362
+ * Get visuals from platform (read-only)
3363
+ */
3364
+ app.get("/api/remote/visuals", async (req, res, next) => {
3365
+ try {
3366
+ let currentSettings = null;
3367
+ try {
3368
+ currentSettings = config.readSettings();
3369
+ } catch (error) {
3370
+ return res
3371
+ .status(400)
3372
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
3373
+ }
3374
+
3375
+ if (!currentSettings?.projectId) {
3376
+ return res.status(400).json({ error: "Missing projectId in settings" });
3377
+ }
3378
+
3379
+ if (!currentSettings?.apiKey) {
3380
+ return res.status(400).json({ error: "Missing apiKey in settings" });
3381
+ }
3382
+
3383
+ try {
3384
+ const visuals = await apiClient.getVisuals(
3385
+ currentSettings.projectId,
3386
+ currentSettings.apiKey,
3387
+ );
3388
+ res.json({ visuals });
3389
+ } catch (error) {
3390
+ // Gracefully handle API errors (endpoint might not exist or require auth)
3391
+ console.warn("Failed to fetch visuals from platform:", error.message);
3392
+ res.json({ visuals: [], error: error.message });
3393
+ }
3394
+ } catch (error) {
3395
+ next(error);
3396
+ }
3397
+ });
3398
+
3399
+ /**
3400
+ * GET /api/remote/review-queue
3401
+ * Get review queue from platform (read-only)
3402
+ */
3403
+ app.get("/api/remote/review-queue", async (req, res, next) => {
3404
+ try {
3405
+ let currentSettings = null;
3406
+ try {
3407
+ currentSettings = config.readSettings();
3408
+ } catch (error) {
3409
+ return res
3410
+ .status(400)
3411
+ .json({ error: "No CLI settings found. Run `reshot auth` first." });
3412
+ }
3413
+
3414
+ if (!currentSettings?.projectId) {
3415
+ return res.status(400).json({ error: "Missing projectId in settings" });
3416
+ }
3417
+
3418
+ if (!currentSettings?.apiKey) {
3419
+ return res.status(400).json({ error: "Missing apiKey in settings" });
3420
+ }
3421
+
3422
+ try {
3423
+ const queue = await apiClient.getReviewQueue(
3424
+ currentSettings.projectId,
3425
+ currentSettings.apiKey,
3426
+ );
3427
+ res.json({ queue });
3428
+ } catch (error) {
3429
+ // Gracefully handle API errors (endpoint might not exist)
3430
+ console.warn(
3431
+ "Failed to fetch review queue from platform:",
3432
+ error.message,
3433
+ );
3434
+ res.json({ queue: [], error: error.message });
3435
+ }
3436
+ } catch (error) {
3437
+ next(error);
3438
+ }
3439
+ });
3440
+
3441
+ // ===== JOBS API =====
3442
+ const uiExecutor = require("./ui-executor");
3443
+
3444
+ /**
3445
+ * GET /api/jobs
3446
+ * List all jobs
3447
+ */
3448
+ app.get("/api/jobs", async (req, res, next) => {
3449
+ try {
3450
+ // Clean up stuck jobs before returning
3451
+ uiExecutor.cleanupStuckJobs();
3452
+ const limit = parseInt(req.query.limit || "50", 10);
3453
+ const jobs = uiExecutor.getAllJobs(limit);
3454
+ res.json({ jobs });
3455
+ } catch (error) {
3456
+ next(error);
3457
+ }
3458
+ });
3459
+
3460
+ /**
3461
+ * POST /api/jobs/cleanup
3462
+ * Manually trigger cleanup of stuck jobs
3463
+ */
3464
+ app.post("/api/jobs/cleanup", async (req, res, next) => {
3465
+ try {
3466
+ const cleaned = uiExecutor.cleanupStuckJobs();
3467
+ res.json({ ok: true, cleaned });
3468
+ } catch (error) {
3469
+ next(error);
3470
+ }
3471
+ });
3472
+
3473
+ /**
3474
+ * GET /api/jobs/:id
3475
+ * Get a single job
3476
+ */
3477
+ app.get("/api/jobs/:id", async (req, res, next) => {
3478
+ try {
3479
+ const job = uiExecutor.getJob(req.params.id);
3480
+ if (!job) {
3481
+ return res.status(404).json({ error: "Job not found" });
3482
+ }
3483
+ res.json({ job });
3484
+ } catch (error) {
3485
+ next(error);
3486
+ }
3487
+ });
3488
+
3489
+ /**
3490
+ * GET /api/jobs/:id/logs
3491
+ * Get job logs (tail)
3492
+ */
3493
+ app.get("/api/jobs/:id/logs", async (req, res, next) => {
3494
+ try {
3495
+ const job = uiExecutor.getJob(req.params.id);
3496
+ if (!job) {
3497
+ return res.status(404).json({ error: "Job not found" });
3498
+ }
3499
+ const tail = parseInt(req.query.tail || "100", 10);
3500
+ const logs = job.logs.slice(-tail);
3501
+ res.json({ logs });
3502
+ } catch (error) {
3503
+ next(error);
3504
+ }
3505
+ });
3506
+
3507
+ /**
3508
+ * POST /api/jobs/:id/cancel
3509
+ * Cancel a running job
3510
+ */
3511
+ app.post("/api/jobs/:id/cancel", async (req, res, next) => {
3512
+ try {
3513
+ const jobId = req.params.id;
3514
+ const job = uiExecutor.getJob(jobId);
3515
+
3516
+ if (!job) {
3517
+ return res.status(404).json({ error: "Job not found" });
3518
+ }
3519
+
3520
+ if (job.status !== "running") {
3521
+ return res
3522
+ .status(400)
3523
+ .json({ error: `Cannot cancel job with status: ${job.status}` });
3524
+ }
3525
+
3526
+ const cancelled = uiExecutor.cancelJob(jobId);
3527
+ res.json({ ok: true, cancelled });
3528
+ } catch (error) {
3529
+ next(error);
3530
+ }
3531
+ });
3532
+
3533
+ /**
3534
+ * POST /api/jobs/run
3535
+ * Create and execute a run job
3536
+ * @param {string[]} scenarioKeys - Scenario keys to run
3537
+ * @param {object} variant - Variant override
3538
+ * @param {string} format - Output format override: 'step-by-step-images' | 'summary-video'
3539
+ * @param {boolean} diff - Enable baseline diffing (optional, uses config if not specified)
3540
+ */
3541
+ app.post("/api/jobs/run", async (req, res, next) => {
3542
+ try {
3543
+ const { scenarioKeys, variant, format, diff, noPrivacy, noStyle } = req.body;
3544
+ const job = uiExecutor.createJob("run", {
3545
+ scenarioKeys,
3546
+ variant,
3547
+ format,
3548
+ diff,
3549
+ noPrivacy,
3550
+ noStyle,
3551
+ });
3552
+
3553
+ // Execute asynchronously - don't await, return immediately
3554
+ setImmediate(async () => {
3555
+ try {
3556
+ await uiExecutor.executeRunJob(
3557
+ job.id,
3558
+ scenarioKeys,
3559
+ variant,
3560
+ format,
3561
+ diff,
3562
+ noPrivacy,
3563
+ noStyle,
3564
+ );
3565
+ } catch (err) {
3566
+ console.error("Run job execution failed:", err);
3567
+ // Error already logged in executor
3568
+ }
3569
+ });
3570
+
3571
+ res.status(201).json({ ok: true, job });
3572
+ } catch (error) {
3573
+ next(error);
3574
+ }
3575
+ });
3576
+
3577
+ /**
3578
+ * POST /api/jobs/run-all-variations
3579
+ * Run a scenario with all possible variant combinations
3580
+ */
3581
+ app.post("/api/jobs/run-all-variations", async (req, res, next) => {
3582
+ try {
3583
+ const {
3584
+ scenarioKey,
3585
+ dimensions,
3586
+ format = "step-by-step-images",
3587
+ } = req.body;
3588
+
3589
+ if (!scenarioKey) {
3590
+ return res.status(400).json({ error: "scenarioKey is required" });
3591
+ }
3592
+
3593
+ // Get variants config from current config
3594
+ const currentConfig = config.configExists() ? config.readConfig() : {};
3595
+ const variantsConfig = currentConfig?.variants || {};
3596
+ const allDimensions = variantsConfig.dimensions || {};
3597
+
3598
+ // Get scenario to use its name
3599
+ const scenario = currentConfig.scenarios?.find(
3600
+ (s) => s.key === scenarioKey,
3601
+ );
3602
+ const scenarioName = scenario?.name || scenarioKey;
3603
+
3604
+ // Use provided dimensions or all available dimensions
3605
+ const dimensionsToUse = dimensions || Object.keys(allDimensions);
3606
+
3607
+ // Generate all combinations
3608
+ const combinations = generateVariantCombinations(
3609
+ allDimensions,
3610
+ dimensionsToUse,
3611
+ );
3612
+
3613
+ if (combinations.length === 0) {
3614
+ return res
3615
+ .status(400)
3616
+ .json({ error: "No variant combinations available" });
3617
+ }
3618
+
3619
+ // Create a job for each combination
3620
+ const jobs = [];
3621
+ const formatLabel = format === "summary-video" ? "Video" : "Screenshots";
3622
+
3623
+ for (const variant of combinations) {
3624
+ // Build a human-readable variant label
3625
+ const variantParts = [];
3626
+ for (const [dimKey, optionValue] of Object.entries(variant)) {
3627
+ const dimension = allDimensions[dimKey];
3628
+ if (dimension) {
3629
+ // Find option label or use value (options is an object keyed by option ID)
3630
+ const option = dimension.options?.[optionValue];
3631
+ const label = option?.name || option?.label || optionValue;
3632
+ variantParts.push(label);
3633
+ } else {
3634
+ variantParts.push(optionValue);
3635
+ }
3636
+ }
3637
+ const variantLabel = variantParts.join(" • ");
3638
+
3639
+ // Create descriptive job name: "Scenario Name [English • Admin • Light] - Screenshots"
3640
+ const jobDescription = `${scenarioName} [${variantLabel}] - ${formatLabel}`;
3641
+
3642
+ const job = uiExecutor.createJob("run", {
3643
+ scenarioKeys: [scenarioKey],
3644
+ variant,
3645
+ format,
3646
+ description: jobDescription,
3647
+ });
3648
+
3649
+ jobs.push(job);
3650
+
3651
+ // Execute asynchronously
3652
+ setImmediate(async () => {
3653
+ try {
3654
+ await uiExecutor.executeRunJob(
3655
+ job.id,
3656
+ [scenarioKey],
3657
+ variant,
3658
+ format,
3659
+ );
3660
+ } catch (err) {
3661
+ console.error(`Run job ${job.id} execution failed:`, err);
3662
+ }
3663
+ });
3664
+ }
3665
+
3666
+ res.status(201).json({
3667
+ ok: true,
3668
+ jobs,
3669
+ totalVariations: combinations.length,
3670
+ combinations,
3671
+ format,
3672
+ });
3673
+ } catch (error) {
3674
+ next(error);
3675
+ }
3676
+ });
3677
+
3678
+ /**
3679
+ * POST /api/jobs/publish
3680
+ * Create and execute a publish job - directly calls the platform API
3681
+ * Pre-checks auth before starting the job
3682
+ */
3683
+ app.post("/api/jobs/publish", async (req, res, next) => {
3684
+ try {
3685
+ const { scenarioKeys, selectedGroups, commitMessage } = req.body;
3686
+
3687
+ // Pre-check authentication before starting job
3688
+ let currentSettings;
3689
+ try {
3690
+ currentSettings = config.readSettings();
3691
+ } catch (err) {
3692
+ return res
3693
+ .status(401)
3694
+ .json(
3695
+ config.createAuthErrorResponse(
3696
+ "Not authenticated. Please connect first.",
3697
+ ),
3698
+ );
3699
+ }
3700
+
3701
+ if (!currentSettings?.apiKey || !currentSettings?.projectId) {
3702
+ return res
3703
+ .status(401)
3704
+ .json(
3705
+ config.createAuthErrorResponse(
3706
+ "API key or project ID not found. Please connect.",
3707
+ ),
3708
+ );
3709
+ }
3710
+
3711
+ // Verify API key is still valid before starting publish
3712
+ const axios = require("axios");
3713
+ const platformUrl = getPlatformUrl(currentSettings);
3714
+ try {
3715
+ await axios.get(`${platformUrl}/api/auth/cli/verify`, {
3716
+ headers: { Authorization: `Bearer ${currentSettings.apiKey}` },
3717
+ timeout: 10000,
3718
+ });
3719
+ } catch (verifyError) {
3720
+ if (config.isAuthError(verifyError)) {
3721
+ return res
3722
+ .status(401)
3723
+ .json(
3724
+ config.createAuthErrorResponse(
3725
+ "Your API key has expired. Please reconnect to the platform.",
3726
+ ),
3727
+ );
3728
+ }
3729
+ // Network error - continue anyway, might work
3730
+ console.warn(
3731
+ "Could not verify API key before publish:",
3732
+ verifyError.message,
3733
+ );
3734
+ }
3735
+
3736
+ // Extract scenario keys from selectedGroups if provided
3737
+ const effectiveScenarioKeys = selectedGroups
3738
+ ? [...new Set(selectedGroups.map((g) => g.scenarioKey))]
3739
+ : scenarioKeys;
3740
+
3741
+ const job = uiExecutor.createJob("publish", {
3742
+ scenarioKeys: effectiveScenarioKeys,
3743
+ selectedGroups,
3744
+ commitMessage,
3745
+ });
3746
+
3747
+ // Execute directly - don't spawn CLI subprocess
3748
+ setImmediate(async () => {
3749
+ try {
3750
+ await executeDirectPublish(job.id, selectedGroups, commitMessage);
3751
+ } catch (err) {
3752
+ console.error("Publish job execution failed:", err);
3753
+ uiExecutor.appendJobLog(job.id, `[error] ${err.message}`);
3754
+
3755
+ // Check if this is an auth error and mark job appropriately
3756
+ if (config.isAuthError(err)) {
3757
+ uiExecutor.updateJobStatus(job.id, "failed", {
3758
+ error: err.message,
3759
+ authRequired: true,
3760
+ });
3761
+ } else {
3762
+ uiExecutor.updateJobStatus(job.id, "failed", {
3763
+ error: err.message,
3764
+ });
3765
+ }
3766
+ }
3767
+ });
3768
+
3769
+ res.status(201).json({ ok: true, job });
3770
+ } catch (error) {
3771
+ next(error);
3772
+ }
3773
+ });
3774
+
3775
+ /**
3776
+ * Load all diff manifests from the output directory for attaching diff data to assets
3777
+ * @returns {Map} Map of "scenarioKey" -> manifest data
3778
+ */
3779
+ function loadDiffManifestsForPublish(outputBaseDir) {
3780
+ const manifests = new Map();
3781
+ if (!fs.existsSync(outputBaseDir)) return manifests;
3782
+
3783
+ try {
3784
+ const scenarios = fs.readdirSync(outputBaseDir).filter((item) => {
3785
+ const fullPath = path.join(outputBaseDir, item);
3786
+ return fs.statSync(fullPath).isDirectory();
3787
+ });
3788
+
3789
+ for (const scenarioKey of scenarios) {
3790
+ const scenarioDir = path.join(outputBaseDir, scenarioKey);
3791
+ const versions = fs.readdirSync(scenarioDir).filter((item) => {
3792
+ const fullPath = path.join(scenarioDir, item);
3793
+ return (
3794
+ fs.statSync(fullPath).isDirectory() &&
3795
+ /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(item)
3796
+ );
3797
+ });
3798
+
3799
+ // Get the latest version (sorted desc)
3800
+ const latestVersion = versions.sort().reverse()[0];
3801
+ if (!latestVersion) continue;
3802
+
3803
+ // Check for manifest in root AND in variant subdirectories
3804
+ const latestVersionDir = path.join(scenarioDir, latestVersion);
3805
+
3806
+ // Recursive search for diff-manifest.json files
3807
+ const findManifests = (dir, relativePath = "") => {
3808
+ const items = fs.readdirSync(dir);
3809
+ for (const item of items) {
3810
+ const fullPath = path.join(dir, item);
3811
+ if (item === "diff-manifest.json") {
3812
+ try {
3813
+ const manifest = fs.readJSONSync(fullPath);
3814
+ const key = relativePath
3815
+ ? `${scenarioKey}/${relativePath}`
3816
+ : scenarioKey;
3817
+ manifests.set(key, manifest);
3818
+ } catch (e) {
3819
+ /* skip malformed */
3820
+ }
3821
+ } else if (
3822
+ fs.statSync(fullPath).isDirectory() &&
3823
+ item !== "diffs"
3824
+ ) {
3825
+ findManifests(
3826
+ fullPath,
3827
+ relativePath ? `${relativePath}/${item}` : item,
3828
+ );
3829
+ }
3830
+ }
3831
+ };
3832
+
3833
+ findManifests(latestVersionDir);
3834
+ }
3835
+ } catch (e) {
3836
+ /* ignore */
3837
+ }
3838
+ return manifests;
3839
+ }
3840
+
3841
+ /**
3842
+ * Get diff data for an asset from loaded manifests
3843
+ */
3844
+ function getDiffDataFromManifests(manifests, scenarioKey, captureKey) {
3845
+ // Try direct match first - check both with and without trailing slash
3846
+ for (const [key, manifest] of manifests.entries()) {
3847
+ if (key === scenarioKey || key.startsWith(`${scenarioKey}/`)) {
3848
+ const assetData = manifest.assets?.[captureKey];
3849
+ if (assetData) {
3850
+ return {
3851
+ diffPercentage:
3852
+ assetData.score != null ? assetData.score * 100 : null,
3853
+ diffStatus: assetData.status || null,
3854
+ };
3855
+ }
3856
+ // Also check with variant prefix patterns
3857
+ for (const [assetKey, data] of Object.entries(manifest.assets || {})) {
3858
+ if (assetKey.endsWith(`/${captureKey}`) || assetKey === captureKey) {
3859
+ return {
3860
+ diffPercentage: data.score != null ? data.score * 100 : null,
3861
+ diffStatus: data.status || null,
3862
+ };
3863
+ }
3864
+ }
3865
+ }
3866
+ }
3867
+ return null;
3868
+ }
3869
+
3870
+ /**
3871
+ * Execute a direct publish to the platform API
3872
+ * Uses the transactional flow: presigned URLs -> direct R2 upload -> metadata commit
3873
+ * This bypasses Vercel serverless timeout limits
3874
+ */
3875
+ async function executeDirectPublish(jobId, selectedGroups, commitMessage) {
3876
+ uiExecutor.updateJobStatus(jobId, "running");
3877
+ uiExecutor.appendJobLog(
3878
+ jobId,
3879
+ "[info] Starting direct publish to platform...",
3880
+ );
3881
+
3882
+ // Generate a unique session ID for this publish run
3883
+ // This ensures all assets from this run are grouped into ONE commit on the platform
3884
+ const crypto = require("crypto");
3885
+ const publishSessionId = crypto.randomUUID();
3886
+ uiExecutor.appendJobLog(
3887
+ jobId,
3888
+ `[info] Session ID: ${publishSessionId.substring(0, 8)}...`,
3889
+ );
3890
+
3891
+ // Load diff manifests for attaching diff data to assets
3892
+ const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
3893
+ const diffManifests = loadDiffManifestsForPublish(outputBaseDir);
3894
+ if (diffManifests.size > 0) {
3895
+ uiExecutor.appendJobLog(
3896
+ jobId,
3897
+ `[info] Loaded diff data from ${diffManifests.size} scenario(s)`,
3898
+ );
3899
+ }
3900
+
3901
+ // Read settings
3902
+ let currentSettings;
3903
+ try {
3904
+ currentSettings = config.readSettings();
3905
+ } catch (err) {
3906
+ throw new Error("Not authenticated. Run 'reshot auth' first.");
3907
+ }
3908
+
3909
+ const apiKey = currentSettings?.apiKey;
3910
+ const projectId = currentSettings?.projectId;
3911
+
3912
+ if (!apiKey || !projectId) {
3913
+ throw new Error(
3914
+ "API key or project ID not found. Run 'reshot auth' first.",
3915
+ );
3916
+ }
3917
+
3918
+ uiExecutor.appendJobLog(jobId, `[info] Project ID: ${projectId}`);
3919
+ uiExecutor.appendJobLog(
3920
+ jobId,
3921
+ `[info] API Key: ${apiKey.substring(0, 15)}...`,
3922
+ );
3923
+
3924
+ // Read config for scenario metadata
3925
+ let docSyncConfig = null;
3926
+ try {
3927
+ docSyncConfig = config.readConfig();
3928
+ } catch (err) {
3929
+ uiExecutor.appendJobLog(
3930
+ jobId,
3931
+ "[warn] Could not read config, using minimal metadata",
3932
+ );
3933
+ }
3934
+
3935
+ // Get git info
3936
+ const { execSync } = require("child_process");
3937
+ let commitHash = "unknown";
3938
+ let gitBranch = "main";
3939
+ let gitMessage = commitMessage || "CLI publish";
3940
+ try {
3941
+ commitHash = execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
3942
+ try {
3943
+ gitBranch = execSync("git rev-parse --abbrev-ref HEAD", {
3944
+ encoding: "utf-8",
3945
+ }).trim();
3946
+ } catch (branchErr) {
3947
+ // Ignore branch error, use default
3948
+ }
3949
+ if (!commitMessage) {
3950
+ gitMessage = execSync("git log -1 --pretty=%B", {
3951
+ encoding: "utf-8",
3952
+ }).trim();
3953
+ }
3954
+ } catch (err) {
3955
+ uiExecutor.appendJobLog(jobId, "[warn] Could not get git info");
3956
+ }
3957
+
3958
+ if (!selectedGroups || selectedGroups.length === 0) {
3959
+ throw new Error("No assets selected for publishing");
3960
+ }
3961
+
3962
+ uiExecutor.appendJobLog(
3963
+ jobId,
3964
+ `[info] Publishing ${selectedGroups.length} variation group(s) using transactional flow`,
3965
+ );
3966
+
3967
+ let successCount = 0;
3968
+ let failCount = 0;
3969
+ let skippedCount = 0;
3970
+ let viewUrl = null;
3971
+
3972
+ // Process each group using transactional flow
3973
+ for (const group of selectedGroups) {
3974
+ const { scenarioKey, variationSlug, assets } = group;
3975
+
3976
+ if (!assets || assets.length === 0) {
3977
+ uiExecutor.appendJobLog(
3978
+ jobId,
3979
+ `[warn] Skipping ${scenarioKey}/${variationSlug} - no assets`,
3980
+ );
3981
+ continue;
3982
+ }
3983
+
3984
+ uiExecutor.appendJobLog(
3985
+ jobId,
3986
+ `[info] Processing ${scenarioKey}/${variationSlug} (${assets.length} asset(s))`,
3987
+ );
3988
+
3989
+ // Build metadata - include publishSessionId to group all assets into one commit
3990
+ const scenarioConfig = docSyncConfig?.scenarios?.find(
3991
+ (s) => s.key === scenarioKey,
3992
+ );
3993
+ const metadata = {
3994
+ projectId,
3995
+ publishSessionId, // Groups all assets from this publish run into ONE commit
3996
+ scenarioName: scenarioKey,
3997
+ scenario: scenarioConfig
3998
+ ? {
3999
+ name: scenarioConfig.name || scenarioKey,
4000
+ targetUrl: scenarioConfig.targetUrl,
4001
+ steps: scenarioConfig.steps,
4002
+ }
4003
+ : { name: scenarioKey },
4004
+ context: {
4005
+ name: variationSlug || "default",
4006
+ data: {},
4007
+ },
4008
+ git: {
4009
+ commitHash,
4010
+ commitMessage: gitMessage,
4011
+ branch: gitBranch,
4012
+ },
4013
+ cli: {
4014
+ version: require("../../package.json").version,
4015
+ captureTimestamp: new Date().toISOString(),
4016
+ features: ["steps", "transactional"],
4017
+ },
4018
+ };
4019
+
4020
+ // Prepare files for signing
4021
+ const filesToSign = [];
4022
+ const { hashFile, getMimeType } = require("./hash");
4023
+
4024
+ for (const asset of assets) {
4025
+ if (!fs.existsSync(asset.path)) {
4026
+ uiExecutor.appendJobLog(
4027
+ jobId,
4028
+ `[warn] Asset file not found: ${asset.path}`,
4029
+ );
4030
+ continue;
4031
+ }
4032
+
4033
+ const fileStat = fs.statSync(asset.path);
4034
+ const hash = await hashFile(asset.path);
4035
+ const contentType = getMimeType(asset.path);
4036
+ // Group all assets by scenario+variant, not by individual captureKey
4037
+ // This creates ONE Visual per scenario+variant, with multiple assets (steps)
4038
+ const visualKey = `${scenarioKey}/${variationSlug || "default"}`;
4039
+
4040
+ // Get diff data from manifest if available
4041
+ const diffData = getDiffDataFromManifests(
4042
+ diffManifests,
4043
+ scenarioKey,
4044
+ asset.captureKey,
4045
+ );
4046
+
4047
+ filesToSign.push({
4048
+ key: asset.captureKey, // captureKey identifies the step within the visual
4049
+ visualKey, // visualKey groups all steps into one Visual
4050
+ path: asset.path,
4051
+ size: fileStat.size,
4052
+ contentType,
4053
+ hash,
4054
+ diffPercentage: diffData?.diffPercentage ?? null,
4055
+ diffStatus: diffData?.diffStatus ?? null,
4056
+ });
4057
+ }
4058
+
4059
+ if (filesToSign.length === 0) {
4060
+ uiExecutor.appendJobLog(
4061
+ jobId,
4062
+ `[warn] No valid asset files for ${scenarioKey}/${variationSlug}`,
4063
+ );
4064
+ failCount += assets.length;
4065
+ continue;
4066
+ }
4067
+
4068
+ try {
4069
+ // Step 1: Get presigned URLs
4070
+ uiExecutor.appendJobLog(
4071
+ jobId,
4072
+ `[info] Getting presigned URLs for ${filesToSign.length} files...`,
4073
+ );
4074
+ let signResponse;
4075
+ try {
4076
+ signResponse = await apiClient.signAssets(apiKey, {
4077
+ files: filesToSign.map((f) => ({
4078
+ key: f.key,
4079
+ contentType: f.contentType,
4080
+ size: f.size,
4081
+ hash: f.hash,
4082
+ visualKey: f.visualKey,
4083
+ })),
4084
+ });
4085
+ } catch (signError) {
4086
+ // Log detailed sign error
4087
+ const status = signError.response?.status;
4088
+ const statusText = signError.response?.statusText;
4089
+ const responseData = signError.response?.data;
4090
+ if (status) {
4091
+ uiExecutor.appendJobLog(
4092
+ jobId,
4093
+ `[error] Sign request failed: HTTP ${status} ${statusText || ""}`,
4094
+ );
4095
+ if (responseData?.error) {
4096
+ uiExecutor.appendJobLog(jobId, `[error] ${responseData.error}`);
4097
+ }
4098
+ }
4099
+ throw signError;
4100
+ }
4101
+
4102
+ const { urls } = signResponse;
4103
+
4104
+ if (!urls || Object.keys(urls).length === 0) {
4105
+ throw new Error("No presigned URLs returned from server");
4106
+ }
4107
+
4108
+ // Step 2: Upload files directly to R2 (parallel with concurrency limit)
4109
+ uiExecutor.appendJobLog(
4110
+ jobId,
4111
+ `[info] Uploading ${filesToSign.length} file(s) to storage...`,
4112
+ );
4113
+ const CONCURRENCY = 5;
4114
+ const uploadQueue = [...filesToSign];
4115
+ const uploadResults = [];
4116
+
4117
+ while (uploadQueue.length > 0) {
4118
+ const batch = uploadQueue.splice(0, CONCURRENCY);
4119
+ const batchPromises = batch.map(async (file) => {
4120
+ const compositeKey = `${file.visualKey}:${file.hash}`;
4121
+ const urlInfo = urls[compositeKey] || urls[file.visualKey] || urls[file.key];
4122
+ if (!urlInfo) {
4123
+ return {
4124
+ success: false,
4125
+ file,
4126
+ error: `No presigned URL for ${file.key} (visualKey: ${file.visualKey}, compositeKey: ${compositeKey})`,
4127
+ };
4128
+ }
4129
+
4130
+ try {
4131
+ const fileBuffer = fs.readFileSync(file.path);
4132
+ await apiClient.uploadToPresignedUrl(
4133
+ urlInfo.uploadUrl,
4134
+ fileBuffer,
4135
+ { contentType: file.contentType },
4136
+ );
4137
+ return { success: true, file, s3Path: urlInfo.path };
4138
+ } catch (err) {
4139
+ // Extract detailed error info for debugging
4140
+ const status = err.response?.status;
4141
+ const statusText = err.response?.statusText;
4142
+ const responseData = err.response?.data;
4143
+ const errorDetail = status
4144
+ ? `HTTP ${status} ${statusText || ""} - ${
4145
+ typeof responseData === "string"
4146
+ ? responseData
4147
+ : JSON.stringify(responseData) || err.message
4148
+ }`
4149
+ : err.message;
4150
+ return { success: false, file, error: errorDetail };
4151
+ }
4152
+ });
4153
+
4154
+ const results = await Promise.all(batchPromises);
4155
+ uploadResults.push(...results);
4156
+ }
4157
+
4158
+ // Count successes and failures
4159
+ const successfulUploads = uploadResults.filter((r) => r.success);
4160
+ const failedUploads = uploadResults.filter((r) => !r.success);
4161
+
4162
+ if (failedUploads.length > 0) {
4163
+ for (const failed of failedUploads.slice(0, 5)) {
4164
+ // Only log first 5 errors
4165
+ uiExecutor.appendJobLog(
4166
+ jobId,
4167
+ `[warn] Failed to upload ${failed.file.key}: ${failed.error}`,
4168
+ );
4169
+ }
4170
+ if (failedUploads.length > 5) {
4171
+ uiExecutor.appendJobLog(
4172
+ jobId,
4173
+ `[warn] ... and ${failedUploads.length - 5} more upload failures`,
4174
+ );
4175
+ }
4176
+ }
4177
+
4178
+ if (successfulUploads.length === 0) {
4179
+ throw new Error("All file uploads failed");
4180
+ }
4181
+
4182
+ // Step 3: Commit metadata to platform
4183
+ uiExecutor.appendJobLog(jobId, `[info] Committing metadata...`);
4184
+ const commitAssets = successfulUploads.map((r) => ({
4185
+ key: r.file.key,
4186
+ s3Path: r.s3Path,
4187
+ hash: r.file.hash,
4188
+ visualKey: r.file.visualKey,
4189
+ size: r.file.size,
4190
+ contentType: r.file.contentType,
4191
+ diffPercentage: r.file.diffPercentage,
4192
+ diffStatus: r.file.diffStatus,
4193
+ }));
4194
+
4195
+ const result = await apiClient.publishTransactional(apiKey, {
4196
+ metadata,
4197
+ assets: commitAssets,
4198
+ });
4199
+
4200
+ const processedCount =
4201
+ result?.assetsProcessed ?? successfulUploads.length;
4202
+ uiExecutor.appendJobLog(
4203
+ jobId,
4204
+ `[success] Published ${processedCount} asset(s) for ${scenarioKey}/${variationSlug}`,
4205
+ );
4206
+ successCount += processedCount;
4207
+ failCount += failedUploads.length;
4208
+
4209
+ // Handle skipped assets (visual limit)
4210
+ if (result?.skippedAssets?.length > 0) {
4211
+ for (const key of result.skippedAssets) {
4212
+ uiExecutor.appendJobLog(
4213
+ jobId,
4214
+ `[warn] Skipped "${key}" (plan limit reached)`,
4215
+ );
4216
+ }
4217
+ skippedCount += result.skippedAssets.length;
4218
+ }
4219
+
4220
+ // Capture viewUrl from first successful response
4221
+ if (!viewUrl && result?.viewUrl) {
4222
+ viewUrl = result.viewUrl;
4223
+ }
4224
+ } catch (err) {
4225
+ // Check if this is an auth error - if so, fail immediately with auth message
4226
+ if (config.isAuthError(err)) {
4227
+ uiExecutor.appendJobLog(
4228
+ jobId,
4229
+ `[error] Authentication failed: ${err.message}`,
4230
+ );
4231
+ uiExecutor.appendJobLog(
4232
+ jobId,
4233
+ `[error] Your API key may have expired. Please reconnect to the platform.`,
4234
+ );
4235
+ throw new Error(
4236
+ "Authentication failed. Please reconnect to the platform.",
4237
+ );
4238
+ }
4239
+ uiExecutor.appendJobLog(
4240
+ jobId,
4241
+ `[error] Failed ${scenarioKey}/${variationSlug}: ${err.message}`,
4242
+ );
4243
+ failCount += filesToSign.length;
4244
+ }
4245
+ }
4246
+
4247
+ let summaryMsg = `[info] Publish complete: ${successCount} succeeded, ${failCount} failed`;
4248
+ if (skippedCount > 0) {
4249
+ summaryMsg += `, ${skippedCount} skipped (plan limit)`;
4250
+ }
4251
+ uiExecutor.appendJobLog(jobId, summaryMsg);
4252
+
4253
+ if (viewUrl) {
4254
+ uiExecutor.appendJobLog(jobId, `[info] View in platform: ${viewUrl}`);
4255
+ }
4256
+
4257
+ if (failCount > 0 && successCount === 0) {
4258
+ throw new Error(`All uploads failed (${failCount} assets)`);
4259
+ }
4260
+
4261
+ uiExecutor.updateJobStatus(jobId, "success", {
4262
+ successCount,
4263
+ failCount,
4264
+ skippedCount,
4265
+ viewUrl,
4266
+ });
4267
+ }
4268
+
4269
+ /**
4270
+ * POST /api/jobs/record
4271
+ * Create and execute a record job
4272
+ */
4273
+ app.post("/api/jobs/record", async (req, res, next) => {
4274
+ try {
4275
+ const { title, scenarioKey } = req.body;
4276
+ if (!title) {
4277
+ return res
4278
+ .status(400)
4279
+ .json({ error: "Title is required for record job" });
4280
+ }
4281
+
4282
+ const job = uiExecutor.createJob("record", { title, scenarioKey });
4283
+
4284
+ // Execute asynchronously - don't await, return immediately
4285
+ // Note: Record is interactive and may require Chrome to be running
4286
+ setImmediate(async () => {
4287
+ try {
4288
+ await uiExecutor.executeRecordJob(job.id, title, scenarioKey);
4289
+ } catch (err) {
4290
+ console.error("Record job execution failed:", err);
4291
+ // Error already logged in executor
4292
+ }
4293
+ });
4294
+
4295
+ res.status(201).json({ ok: true, job });
4296
+ } catch (error) {
4297
+ next(error);
4298
+ }
4299
+ });
4300
+
4301
+ // ===== RECORDER ENDPOINTS =====
4302
+ // These endpoints require the recorderService to be passed in context
4303
+
4304
+ const { checkCdpEndpoint, getCdpTargets } = require("./record-cdp");
4305
+
4306
+ /**
4307
+ * GET /api/recorder/check-chrome
4308
+ * Check if Chrome is running with remote debugging and get available tabs
4309
+ */
4310
+ app.get("/api/recorder/check-chrome", async (req, res, next) => {
4311
+ try {
4312
+ const endpointCheck = await checkCdpEndpoint("localhost", 9222);
4313
+
4314
+ if (!endpointCheck.available) {
4315
+ return res.json({
4316
+ ok: false,
4317
+ chromeAvailable: false,
4318
+ error: endpointCheck.error,
4319
+ instructions: {
4320
+ darwin:
4321
+ '/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"',
4322
+ win32:
4323
+ '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.reshot\\chrome-debug"',
4324
+ linux:
4325
+ 'google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"',
4326
+ },
4327
+ });
4328
+ }
4329
+
4330
+ // Get list of available tabs
4331
+ let targets = [];
4332
+ try {
4333
+ targets = await getCdpTargets("localhost", 9222);
4334
+ } catch (e) {
4335
+ // Ignore, we'll just have empty targets
4336
+ }
4337
+
4338
+ const pageTargets = targets.filter((t) => t.type === "page");
4339
+ const validTargets = pageTargets.filter(
4340
+ (t) =>
4341
+ !t.url.startsWith("chrome://") &&
4342
+ !t.url.startsWith("chrome-error://") &&
4343
+ t.url !== "about:blank",
4344
+ );
4345
+
4346
+ res.json({
4347
+ ok: true,
4348
+ chromeAvailable: true,
4349
+ browserInfo: endpointCheck.info,
4350
+ tabs: pageTargets.map((t) => ({
4351
+ title: t.title,
4352
+ url: t.url,
4353
+ isValid:
4354
+ !t.url.startsWith("chrome://") &&
4355
+ !t.url.startsWith("chrome-error://") &&
4356
+ t.url !== "about:blank",
4357
+ })),
4358
+ hasValidTab: validTargets.length > 0,
4359
+ message:
4360
+ validTargets.length > 0
4361
+ ? `Chrome ready with ${validTargets.length} valid tab(s)`
4362
+ : "Chrome is running but no valid tabs found. Please navigate to your application.",
4363
+ });
4364
+ } catch (error) {
4365
+ res.json({
4366
+ ok: false,
4367
+ chromeAvailable: false,
4368
+ error: error.message,
4369
+ });
4370
+ }
4371
+ });
4372
+
4373
+ /**
4374
+ * GET /api/recorder/status
4375
+ * Get current recorder session status
4376
+ */
4377
+ app.get("/api/recorder/status", async (req, res, next) => {
4378
+ try {
4379
+ const { recorderService } = context;
4380
+ if (!recorderService) {
4381
+ return res.json({
4382
+ ok: true,
4383
+ status: { active: false, error: "Recorder service not available" },
4384
+ });
4385
+ }
4386
+
4387
+ const status = recorderService.getStatus();
4388
+ res.json({ ok: true, status });
4389
+ } catch (error) {
4390
+ next(error);
4391
+ }
4392
+ });
4393
+
4394
+ /**
4395
+ * GET /api/recorder/steps
4396
+ * Get captured steps from current or last session
4397
+ */
4398
+ app.get("/api/recorder/steps", async (req, res, next) => {
4399
+ try {
4400
+ const { recorderService } = context;
4401
+ if (!recorderService) {
4402
+ return res.json({ ok: true, steps: [] });
4403
+ }
4404
+
4405
+ const steps = recorderService.getSteps();
4406
+ res.json({ ok: true, steps });
4407
+ } catch (error) {
4408
+ next(error);
4409
+ }
4410
+ });
4411
+
4412
+ /**
4413
+ * GET /api/recorder/tabs
4414
+ * List available Chrome tabs for recording
4415
+ */
4416
+ app.get("/api/recorder/tabs", async (req, res, next) => {
4417
+ try {
4418
+ const { getCdpTargets, checkCdpEndpoint } = require("./record-cdp");
4419
+
4420
+ // Check if Chrome is available
4421
+ const endpointCheck = await checkCdpEndpoint("localhost", 9222);
4422
+ if (!endpointCheck.available) {
4423
+ return res.json({
4424
+ ok: false,
4425
+ chromeAvailable: false,
4426
+ error: "Chrome is not running with remote debugging enabled",
4427
+ tabs: [],
4428
+ });
4429
+ }
4430
+
4431
+ // Get available tabs
4432
+ const targets = await getCdpTargets("localhost", 9222);
4433
+ const tabs = targets
4434
+ .filter((t) => t.type === "page")
4435
+ .map((t) => ({
4436
+ id: t.id,
4437
+ url: t.url,
4438
+ title: t.title || t.url,
4439
+ // Flag tabs that shouldn't be recorded
4440
+ isOurUI:
4441
+ t.url.includes("localhost:4300") ||
4442
+ t.url.includes("127.0.0.1:4300"),
4443
+ isChrome:
4444
+ t.url.startsWith("chrome://") ||
4445
+ t.url.startsWith("chrome-error://") ||
4446
+ t.url === "about:blank",
4447
+ }))
4448
+ // Sort: real pages first, our UI last
4449
+ .sort((a, b) => {
4450
+ if (a.isOurUI && !b.isOurUI) return 1;
4451
+ if (!a.isOurUI && b.isOurUI) return -1;
4452
+ if (a.isChrome && !b.isChrome) return 1;
4453
+ if (!a.isChrome && b.isChrome) return -1;
4454
+ return 0;
4455
+ });
4456
+
4457
+ res.json({ ok: true, chromeAvailable: true, tabs });
4458
+ } catch (error) {
4459
+ console.error("[Recorder API] Get tabs failed:", error);
4460
+ res
4461
+ .status(500)
4462
+ .json({ error: error.message || "Failed to get Chrome tabs" });
4463
+ }
4464
+ });
4465
+
4466
+ /**
4467
+ * POST /api/recorder/start
4468
+ * Start a new recording session
4469
+ */
4470
+ app.post("/api/recorder/start", async (req, res, next) => {
4471
+ try {
4472
+ const { recorderService } = context;
4473
+ if (!recorderService) {
4474
+ return res
4475
+ .status(503)
4476
+ .json({ error: "Recorder service not available" });
4477
+ }
4478
+
4479
+ const { visualKey, title, targetUrl, targetId, scenarioUrl } = req.body;
4480
+
4481
+ const result = await recorderService.start({
4482
+ visualKey,
4483
+ title,
4484
+ targetUrl, // Specific URL to record
4485
+ targetId, // Specific tab ID to record
4486
+ scenarioUrl, // Custom URL to save with the scenario (defaults to targetUrl if not provided)
4487
+ uiMode: true, // Important: Skip terminal prompts
4488
+ });
4489
+
4490
+ res.json({ ok: true, ...result });
4491
+ } catch (error) {
4492
+ console.error("[Recorder API] Start failed:", error);
4493
+ res
4494
+ .status(500)
4495
+ .json({ error: error.message || "Failed to start recording" });
4496
+ }
4497
+ });
4498
+
4499
+ /**
4500
+ * POST /api/recorder/stop
4501
+ * Stop the current recording session
4502
+ */
4503
+ app.post("/api/recorder/stop", async (req, res, next) => {
4504
+ try {
4505
+ const { recorderService } = context;
4506
+ if (!recorderService) {
4507
+ return res
4508
+ .status(503)
4509
+ .json({ error: "Recorder service not available" });
4510
+ }
4511
+
4512
+ const { save = true, mergeMode = "replace" } = req.body;
4513
+
4514
+ const result = await recorderService.stop(save, {
4515
+ uiMode: true,
4516
+ mergeMode,
4517
+ });
4518
+
4519
+ res.json({ ok: true, ...result });
4520
+ } catch (error) {
4521
+ console.error("[Recorder API] Stop failed:", error);
4522
+ res
4523
+ .status(500)
4524
+ .json({ error: error.message || "Failed to stop recording" });
4525
+ }
4526
+ });
4527
+
4528
+ /**
4529
+ * POST /api/recorder/capture
4530
+ * Capture a screenshot during recording
4531
+ */
4532
+ app.post("/api/recorder/capture", async (req, res, next) => {
4533
+ try {
4534
+ const { recorderService } = context;
4535
+ if (!recorderService) {
4536
+ return res
4537
+ .status(503)
4538
+ .json({ error: "Recorder service not available" });
4539
+ }
4540
+
4541
+ const { outputFilename, areaType, selector } = req.body;
4542
+
4543
+ const step = await recorderService.capture({
4544
+ outputFilename,
4545
+ areaType: areaType || "full",
4546
+ selector,
4547
+ uiMode: true,
4548
+ });
4549
+
4550
+ res.json({ ok: true, step });
4551
+ } catch (error) {
4552
+ console.error("[Recorder API] Capture failed:", error);
4553
+ res
4554
+ .status(500)
4555
+ .json({ error: error.message || "Failed to capture screenshot" });
4556
+ }
4557
+ });
4558
+
4559
+ /**
4560
+ * DELETE /api/recorder/steps/:index
4561
+ * Remove a step at a specific index during recording
4562
+ */
4563
+ app.delete("/api/recorder/steps/:index", async (req, res, next) => {
4564
+ try {
4565
+ const { recorderService } = context;
4566
+ if (!recorderService) {
4567
+ return res
4568
+ .status(503)
4569
+ .json({ error: "Recorder service not available" });
4570
+ }
4571
+
4572
+ const index = parseInt(req.params.index, 10);
4573
+ if (isNaN(index)) {
4574
+ return res.status(400).json({ error: "Invalid step index" });
4575
+ }
4576
+
4577
+ const result = recorderService.removeStep(index);
4578
+ res.json({ ok: true, ...result });
4579
+ } catch (error) {
4580
+ console.error("[Recorder API] Remove step failed:", error);
4581
+ res.status(500).json({ error: error.message || "Failed to remove step" });
4582
+ }
4583
+ });
4584
+
4585
+ /**
4586
+ * POST /api/recorder/save-session
4587
+ * Save the current Chrome session state (cookies, localStorage) for use in captures
4588
+ * This allows captures to run with authenticated sessions without manual login
4589
+ */
4590
+ app.post("/api/recorder/save-session", async (req, res, next) => {
4591
+ try {
4592
+ const {
4593
+ saveSessionState,
4594
+ getDefaultSessionPath,
4595
+ } = require("./record-cdp");
4596
+
4597
+ const sessionPath = getDefaultSessionPath();
4598
+ const result = await saveSessionState(sessionPath);
4599
+
4600
+ if (result.success) {
4601
+ res.json({
4602
+ ok: true,
4603
+ path: result.path,
4604
+ message:
4605
+ "Session saved successfully. Captures will now use your authenticated session.",
4606
+ });
4607
+ } else {
4608
+ res.status(400).json({
4609
+ ok: false,
4610
+ error: result.error,
4611
+ });
4612
+ }
4613
+ } catch (error) {
4614
+ console.error("[Recorder API] Save session failed:", error);
4615
+ res
4616
+ .status(500)
4617
+ .json({ error: error.message || "Failed to save session" });
4618
+ }
4619
+ });
4620
+
4621
+ /**
4622
+ * GET /api/recorder/session-status
4623
+ * Check if a saved session exists and is valid
4624
+ */
4625
+ app.get("/api/recorder/session-status", async (req, res, next) => {
4626
+ try {
4627
+ const { getDefaultSessionPath } = require("./record-cdp");
4628
+ const sessionPath = getDefaultSessionPath();
4629
+
4630
+ if (fs.existsSync(sessionPath)) {
4631
+ const stat = fs.statSync(sessionPath);
4632
+ const ageHours = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
4633
+
4634
+ // Try to parse and get some info
4635
+ try {
4636
+ const sessionData = fs.readJsonSync(sessionPath);
4637
+ res.json({
4638
+ ok: true,
4639
+ hasSession: true,
4640
+ path: sessionPath,
4641
+ savedAt: stat.mtime.toISOString(),
4642
+ ageHours: Math.round(ageHours * 10) / 10,
4643
+ cookieCount: sessionData.cookies?.length || 0,
4644
+ originsCount: sessionData.origins?.length || 0,
4645
+ isStale: ageHours > 24, // Consider stale after 24 hours
4646
+ });
4647
+ } catch (parseError) {
4648
+ res.json({
4649
+ ok: true,
4650
+ hasSession: true,
4651
+ path: sessionPath,
4652
+ error: "Session file is corrupted",
4653
+ });
4654
+ }
4655
+ } else {
4656
+ res.json({
4657
+ ok: true,
4658
+ hasSession: false,
4659
+ message:
4660
+ "No saved session. Use 'Save Session' in Recorder to capture your authenticated state.",
4661
+ });
4662
+ }
4663
+ } catch (error) {
4664
+ console.error("[Recorder API] Session status failed:", error);
4665
+ res
4666
+ .status(500)
4667
+ .json({ error: error.message || "Failed to check session status" });
4668
+ }
4669
+ });
4670
+
4671
+ // Error handler
4672
+ app.use(handleError);
4673
+ }
4674
+
4675
+ module.exports = {
4676
+ attachApiRoutes,
4677
+ };