@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.
- package/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- 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
|
+
};
|