@reshotdev/screenshot 0.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,815 @@
1
+ // api-client.js - API client for communicating with Next.js API
2
+ const axios = require("axios");
3
+ const FormData = require("form-data");
4
+ const fs = require("fs");
5
+
6
+ const baseUrl =
7
+ process.env.RESHOT_API_BASE_URL ||
8
+ process.env.DOCSYNC_API_BASE_URL ||
9
+ "http://localhost:3000/api";
10
+
11
+ function getApiBaseUrl() {
12
+ return baseUrl;
13
+ }
14
+
15
+ /**
16
+ * Sleep helper for retry delays
17
+ */
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ /**
23
+ * Retry wrapper with exponential backoff
24
+ * @param {Function} fn - Async function to retry
25
+ * @param {Object} options - Retry options
26
+ * @returns {Promise<any>}
27
+ */
28
+ async function withRetry(fn, options = {}) {
29
+ const {
30
+ maxRetries = 3,
31
+ initialDelay = 1000,
32
+ maxDelay = 10000,
33
+ retryOn = [500, 502, 503, 504, "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"],
34
+ } = options;
35
+
36
+ let lastError;
37
+
38
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
39
+ try {
40
+ return await fn();
41
+ } catch (error) {
42
+ lastError = error;
43
+
44
+ // Check if this error is retryable
45
+ const statusCode = error.response?.status;
46
+ const errorCode = error.code;
47
+
48
+ const isRetryable =
49
+ retryOn.includes(statusCode) ||
50
+ retryOn.includes(errorCode) ||
51
+ error.message?.includes("timeout");
52
+
53
+ if (!isRetryable || attempt === maxRetries) {
54
+ throw error;
55
+ }
56
+
57
+ // Calculate delay with exponential backoff + jitter
58
+ const delay = Math.min(
59
+ initialDelay * Math.pow(2, attempt - 1) + Math.random() * 1000,
60
+ maxDelay,
61
+ );
62
+ console.log(
63
+ ` ⚠ Request failed (attempt ${attempt}/${maxRetries}), retrying in ${Math.round(
64
+ delay,
65
+ )}ms...`,
66
+ );
67
+ await sleep(delay);
68
+ }
69
+ }
70
+
71
+ throw lastError;
72
+ }
73
+
74
+ /**
75
+ * Get all projects
76
+ */
77
+ async function getProjects() {
78
+ return withRetry(async () => {
79
+ const response = await axios.get(`${baseUrl}/projects`, { timeout: 30000 });
80
+ return response.data;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Get a single project by ID
86
+ */
87
+ async function getProject(id) {
88
+ return withRetry(async () => {
89
+ try {
90
+ const response = await axios.get(`${baseUrl}/projects/${id}`, {
91
+ timeout: 30000,
92
+ });
93
+ return response.data;
94
+ } catch (error) {
95
+ if (error.response && error.response.status === 404) {
96
+ throw new Error(`Project '${id}' not found`);
97
+ }
98
+ throw error;
99
+ }
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Get visuals for a project
105
+ */
106
+ async function getVisuals(projectId, apiKey) {
107
+ return withRetry(async () => {
108
+ const headers = {};
109
+ if (apiKey) {
110
+ headers.Authorization = `Bearer ${apiKey}`;
111
+ }
112
+ const response = await axios.get(
113
+ `${baseUrl}/projects/${projectId}/visuals`,
114
+ { headers, timeout: 30000 },
115
+ );
116
+ return response.data;
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Get visual keys as a Set for efficient validation lookups
122
+ * @param {string} projectId - Project ID
123
+ * @param {string} apiKey - API key for authentication
124
+ * @returns {Promise<Set<string>>} Set of visual keys
125
+ */
126
+ async function getVisualKeys(projectId, apiKey) {
127
+ const data = await getVisuals(projectId, apiKey);
128
+ const visuals = data.visuals || data.data?.visuals || [];
129
+ return new Set(visuals.map((v) => v.key));
130
+ }
131
+
132
+ /**
133
+ * Publish an asset
134
+ */
135
+ async function publishAsset(projectId, assetFilePath, metadata) {
136
+ return withRetry(
137
+ async () => {
138
+ const formData = new FormData();
139
+ formData.append("assetFile", fs.createReadStream(assetFilePath));
140
+ formData.append("apiKey", metadata.apiKey);
141
+ formData.append("visualKey", metadata.visualKey);
142
+ formData.append("context", metadata.context || "{}");
143
+ formData.append("commitHash", metadata.commitHash || "");
144
+ formData.append("commitMessage", metadata.commitMessage || "");
145
+
146
+ const response = await axios.post(
147
+ `${baseUrl}/projects/${projectId}/assets/publish`,
148
+ formData,
149
+ {
150
+ headers: formData.getHeaders(),
151
+ timeout: 60000, // 60s for uploads
152
+ },
153
+ );
154
+ return response.data;
155
+ },
156
+ { maxRetries: 2 },
157
+ ); // Fewer retries for uploads
158
+ }
159
+
160
+ /**
161
+ * Publish a batch of assets using the v1 ingestion endpoint
162
+ */
163
+ async function publishAssetsV1(apiKey, metadata, assets) {
164
+ if (!apiKey) {
165
+ throw new Error("API key is required to publish assets");
166
+ }
167
+ if (!metadata?.projectId) {
168
+ throw new Error("metadata.projectId is required");
169
+ }
170
+ if (!assets || Object.keys(assets).length === 0) {
171
+ throw new Error("At least one asset is required for publishing");
172
+ }
173
+
174
+ const formData = new FormData();
175
+ formData.append("metadata", JSON.stringify(metadata));
176
+
177
+ for (const [captureKey, assetPath] of Object.entries(assets)) {
178
+ formData.append(captureKey, fs.createReadStream(assetPath));
179
+ }
180
+
181
+ try {
182
+ const response = await axios.post(`${baseUrl}/v1/publish`, formData, {
183
+ headers: {
184
+ ...formData.getHeaders(),
185
+ Authorization: `Bearer ${apiKey}`,
186
+ },
187
+ timeout: 180000, // 3 minutes for large uploads
188
+ maxBodyLength: Infinity,
189
+ maxContentLength: Infinity,
190
+ });
191
+ return response.data;
192
+ } catch (error) {
193
+ if (error.response) {
194
+ const status = error.response.status;
195
+ const errorMsg = error.response.data?.error || error.message;
196
+
197
+ // Create an error that preserves the response for auth detection
198
+ const err = new Error(
199
+ status === 401 || status === 403
200
+ ? `Authentication failed: ${errorMsg}`
201
+ : `Failed to publish assets: ${errorMsg}`,
202
+ );
203
+ err.response = error.response;
204
+ throw err;
205
+ }
206
+ throw new Error(`Failed to publish assets: ${error.message}`);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Get review queue for a project
212
+ */
213
+ async function getReviewQueue(projectId, apiKey) {
214
+ try {
215
+ const headers = {};
216
+ if (apiKey) {
217
+ headers.Authorization = `Bearer ${apiKey}`;
218
+ }
219
+ const response = await axios.get(
220
+ `${baseUrl}/projects/${projectId}/review-queue`,
221
+ { headers },
222
+ );
223
+ return response.data;
224
+ } catch (error) {
225
+ if (error.response) {
226
+ // 404 is acceptable - endpoint might not exist yet
227
+ if (error.response.status === 404) {
228
+ return [];
229
+ }
230
+ throw new Error(
231
+ `Failed to fetch review queue: ${error.response.status} ${error.response.statusText}`,
232
+ );
233
+ }
234
+ throw new Error(`Failed to fetch review queue: ${error.message}`);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Publish documentation files
240
+ */
241
+ async function publishDocs(apiKey, docsPayload) {
242
+ if (!apiKey) {
243
+ throw new Error("API key is required to publish docs");
244
+ }
245
+ if (!docsPayload?.projectId) {
246
+ throw new Error("projectId is required in docs payload");
247
+ }
248
+ if (
249
+ !docsPayload.docs ||
250
+ !Array.isArray(docsPayload.docs) ||
251
+ docsPayload.docs.length === 0
252
+ ) {
253
+ throw new Error("At least one doc is required for publishing");
254
+ }
255
+
256
+ // Transform docs to pages format expected by API
257
+ const pages = docsPayload.docs.map((doc) => {
258
+ // Convert path to slug (remove .md/.mdx extension and path separators)
259
+ const slug = doc.path
260
+ .replace(/\.(md|mdx)$/, "")
261
+ .replace(/\\/g, "/")
262
+ .replace(/^\/+|\/+$/g, "");
263
+
264
+ // Extract title from frontmatter or first heading or filename
265
+ let title = doc.frontmatter?.title;
266
+ if (!title) {
267
+ const headingMatch = doc.content.match(/^#\s+(.+)$/m);
268
+ title = headingMatch
269
+ ? headingMatch[1]
270
+ : slug.split("/").pop() || "Untitled";
271
+ }
272
+
273
+ return {
274
+ slug,
275
+ title,
276
+ content: doc.content,
277
+ isIndex:
278
+ slug.endsWith("index") || slug === "README" || doc.frontmatter?.isIndex,
279
+ parentSlug:
280
+ doc.frontmatter?.parent ||
281
+ (slug.includes("/")
282
+ ? slug.split("/").slice(0, -1).join("/")
283
+ : undefined),
284
+ };
285
+ });
286
+
287
+ try {
288
+ const response = await axios.post(
289
+ `${baseUrl}/v1/publish/docs`,
290
+ {
291
+ pages,
292
+ commitHash: docsPayload.commitHash || `cli-${Date.now()}`,
293
+ branch: docsPayload.branch || "main",
294
+ contextKey: docsPayload.contextKey || "default",
295
+ },
296
+ {
297
+ headers: {
298
+ Authorization: `Bearer ${apiKey}`,
299
+ "Content-Type": "application/json",
300
+ },
301
+ },
302
+ );
303
+ return response.data;
304
+ } catch (error) {
305
+ if (error.response) {
306
+ throw new Error(
307
+ `Failed to publish docs: ${error.response.data.error || error.message}`,
308
+ );
309
+ }
310
+ throw new Error(`Failed to publish docs: ${error.message}`);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Post changelog drafts via v1 API
316
+ */
317
+ async function postChangelogDrafts(projectId, commitMessages, apiKey) {
318
+ try {
319
+ const headers = {};
320
+ if (apiKey) {
321
+ headers.Authorization = `Bearer ${apiKey}`;
322
+ }
323
+
324
+ // Transform commitMessages to the expected format
325
+ const commits = Array.isArray(commitMessages)
326
+ ? commitMessages.map((msg, idx) => ({
327
+ commitMessage:
328
+ typeof msg === "string"
329
+ ? msg
330
+ : msg.message || msg.commitMessage || "",
331
+ commitHash: msg.hash || msg.commitHash || `cli-${Date.now()}-${idx}`,
332
+ authorName: msg.author || msg.authorName || "CLI User",
333
+ }))
334
+ : [];
335
+
336
+ const response = await axios.post(
337
+ `${baseUrl}/v1/publish/changelog`,
338
+ {
339
+ commits,
340
+ commitHash: commits[0]?.commitHash || `cli-${Date.now()}`,
341
+ branch: "main",
342
+ },
343
+ { headers },
344
+ );
345
+ return response.data;
346
+ } catch (error) {
347
+ if (error.response) {
348
+ throw new Error(
349
+ `Failed to post changelog drafts: ${
350
+ error.response.data.error || error.message
351
+ }`,
352
+ );
353
+ }
354
+ throw new Error(`Failed to post changelog drafts: ${error.message}`);
355
+ }
356
+ }
357
+
358
+ async function getProjectConfig(projectId, apiKey) {
359
+ const response = await axios.get(`${baseUrl}/projects/${projectId}/config`, {
360
+ headers: {
361
+ Authorization: `Bearer ${apiKey}`,
362
+ },
363
+ });
364
+ const payload = response.data?.data || response.data;
365
+ return payload.config;
366
+ }
367
+
368
+ /**
369
+ * Sync assets to platform
370
+ * @param {string} apiKey - API key for authentication
371
+ * @param {Object} metadata - Sync metadata
372
+ * @param {Object} assetFiles - Map of fileKey to file path
373
+ * @param {Function} onProgress - Progress callback
374
+ * @returns {Promise<Object>} Sync result
375
+ */
376
+ async function syncPushAssets(
377
+ apiKey,
378
+ metadata,
379
+ assetFiles,
380
+ onProgress = () => {},
381
+ ) {
382
+ if (!apiKey) {
383
+ throw new Error("API key is required to sync assets");
384
+ }
385
+ if (!metadata?.projectId) {
386
+ throw new Error("metadata.projectId is required");
387
+ }
388
+ if (!metadata.assets || metadata.assets.length === 0) {
389
+ throw new Error("At least one asset is required for syncing");
390
+ }
391
+
392
+ const formData = new FormData();
393
+ formData.append("metadata", JSON.stringify(metadata));
394
+
395
+ // Add each asset file with its path as the key
396
+ let added = 0;
397
+ for (const asset of metadata.assets) {
398
+ const fileKey = `${asset.scenarioKey}/${asset.variationSlug}/${asset.filename}`;
399
+ const filePath = assetFiles[fileKey];
400
+
401
+ if (!filePath || !fs.existsSync(filePath)) {
402
+ console.warn(`File not found for ${fileKey}: ${filePath}`);
403
+ continue;
404
+ }
405
+
406
+ formData.append(fileKey, fs.createReadStream(filePath));
407
+ added++;
408
+ onProgress({
409
+ type: "file-added",
410
+ fileKey,
411
+ total: metadata.assets.length,
412
+ current: added,
413
+ });
414
+ }
415
+
416
+ if (added === 0) {
417
+ throw new Error("No valid asset files found to sync");
418
+ }
419
+
420
+ onProgress({ type: "uploading", total: added });
421
+
422
+ try {
423
+ const response = await axios.post(`${baseUrl}/v1/sync`, formData, {
424
+ headers: {
425
+ ...formData.getHeaders(),
426
+ Authorization: `Bearer ${apiKey}`,
427
+ },
428
+ maxContentLength: Infinity,
429
+ maxBodyLength: Infinity,
430
+ timeout: 300000, // 5 minutes for large uploads
431
+ });
432
+
433
+ onProgress({ type: "complete", result: response.data });
434
+ return response.data;
435
+ } catch (error) {
436
+ if (error.response) {
437
+ const errData = error.response.data;
438
+ throw new Error(
439
+ `Failed to sync assets: ${
440
+ errData.error || errData.message || error.message
441
+ }`,
442
+ );
443
+ }
444
+ throw new Error(`Failed to sync assets: ${error.message}`);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Get sync status from platform
450
+ * @param {string} apiKey - API key
451
+ * @returns {Promise<Object>} Sync status
452
+ */
453
+ async function getSyncStatus(apiKey) {
454
+ if (!apiKey) {
455
+ throw new Error("API key is required to get sync status");
456
+ }
457
+
458
+ const response = await axios.get(`${baseUrl}/v1/sync`, {
459
+ headers: {
460
+ Authorization: `Bearer ${apiKey}`,
461
+ },
462
+ timeout: 30000,
463
+ });
464
+
465
+ return response.data?.data || response.data;
466
+ }
467
+
468
+ /**
469
+ * Get presigned URLs for direct R2 upload (transactional flow)
470
+ * @param {string} apiKey - API key for authentication
471
+ * @param {Object} payload - { files: [{ key, contentType, size, hash, visualKey }] }
472
+ * @returns {Promise<{ urls: { [key]: { uploadUrl, publicUrl, path } }, projectId, expiresIn }>}
473
+ */
474
+ async function signAssets(apiKey, payload) {
475
+ if (!apiKey) {
476
+ throw new Error("API key is required to sign assets");
477
+ }
478
+
479
+ return withRetry(
480
+ async () => {
481
+ try {
482
+ const response = await axios.post(
483
+ `${baseUrl}/v1/assets/sign`,
484
+ payload,
485
+ {
486
+ headers: {
487
+ "Content-Type": "application/json",
488
+ Authorization: `Bearer ${apiKey}`,
489
+ },
490
+ timeout: 30000,
491
+ },
492
+ );
493
+
494
+ return response.data;
495
+ } catch (err) {
496
+ // Extract detailed error message from response
497
+ if (err.response?.data?.details) {
498
+ const details = err.response.data.details;
499
+ const detailStr = Array.isArray(details)
500
+ ? details
501
+ .map((d) => `${d.path?.join(".")}: ${d.message}`)
502
+ .join(", ")
503
+ : JSON.stringify(details);
504
+ throw new Error(
505
+ `Sign failed: ${
506
+ err.response.data.error || "Validation error"
507
+ } - ${detailStr}`,
508
+ );
509
+ }
510
+ throw err;
511
+ }
512
+ },
513
+ { maxRetries: 3, retryOn: [500, 502, 503, 504, "ECONNRESET", "ETIMEDOUT"] },
514
+ );
515
+ }
516
+
517
+ /**
518
+ * Upload a file directly to R2 using presigned URL
519
+ * @param {string} presignedUrl - The presigned PUT URL (can be relative or absolute)
520
+ * @param {Buffer} fileBuffer - File contents
521
+ * @param {object} options - { contentType: string, headers?: object }
522
+ */
523
+ async function uploadToPresignedUrl(presignedUrl, fileBuffer, options = {}) {
524
+ const { contentType = "application/octet-stream", headers = {} } = options;
525
+
526
+ // Make relative URLs absolute
527
+ let url = presignedUrl;
528
+ let isExternalUpload = false;
529
+
530
+ if (presignedUrl.startsWith("/")) {
531
+ // baseUrl already ends with /api, so if URL starts with /api/, strip it
532
+ if (presignedUrl.startsWith("/api/")) {
533
+ url = `${baseUrl}${presignedUrl.slice(4)}`; // Remove /api prefix
534
+ } else {
535
+ url = `${baseUrl}${presignedUrl}`;
536
+ }
537
+ } else if (
538
+ presignedUrl.startsWith("https://") &&
539
+ !presignedUrl.includes("localhost")
540
+ ) {
541
+ // External presigned URL (R2, S3, etc.) - don't include auth headers
542
+ isExternalUpload = true;
543
+ }
544
+
545
+ // Build headers - exclude Authorization for external presigned URLs
546
+ // as the authentication is embedded in the URL signature
547
+ const requestHeaders = {
548
+ "Content-Type": contentType,
549
+ };
550
+ if (!isExternalUpload) {
551
+ Object.assign(requestHeaders, headers);
552
+ }
553
+
554
+ return withRetry(
555
+ async () => {
556
+ await axios.put(url, fileBuffer, {
557
+ headers: requestHeaders,
558
+ maxBodyLength: Infinity,
559
+ maxContentLength: Infinity,
560
+ timeout: 120000, // 2 minutes for large files
561
+ });
562
+ },
563
+ { maxRetries: 3, retryOn: [500, 502, 503, 504, "ECONNRESET", "ETIMEDOUT"] },
564
+ );
565
+ }
566
+
567
+ /**
568
+ * Publish assets using the transactional flow (assets pre-uploaded to R2)
569
+ * @param {string} apiKey - API key for authentication
570
+ * @param {Object} payload - { metadata, assets: [{ key, s3Path, hash, visualKey, size, contentType }] }
571
+ * @returns {Promise<Object>}
572
+ */
573
+ async function publishTransactional(apiKey, payload) {
574
+ if (!apiKey) {
575
+ throw new Error("API key is required to publish");
576
+ }
577
+
578
+ const response = await axios.post(`${baseUrl}/v1/publish`, payload, {
579
+ headers: {
580
+ "Content-Type": "application/json",
581
+ Authorization: `Bearer ${apiKey}`,
582
+ },
583
+ timeout: 60000,
584
+ });
585
+
586
+ return response.data;
587
+ }
588
+
589
+ /**
590
+ * Check which hashes already exist in storage (for deduplication)
591
+ * @param {string} apiKey - API key for authentication
592
+ * @param {string[]} hashes - Array of content hashes to check
593
+ * @returns {Promise<{ existing: string[], total: number, found: number, new: number }>}
594
+ */
595
+ async function checkExistingHashes(apiKey, hashes) {
596
+ if (!hashes || hashes.length === 0) {
597
+ return { existing: [], total: 0, found: 0, new: 0 };
598
+ }
599
+
600
+ return withRetry(async () => {
601
+ const response = await axios.post(
602
+ `${baseUrl}/v1/assets/check-hashes`,
603
+ { hashes },
604
+ {
605
+ headers: {
606
+ "Content-Type": "application/json",
607
+ Authorization: `Bearer ${apiKey}`,
608
+ },
609
+ timeout: 30000,
610
+ },
611
+ );
612
+
613
+ // Unwrap standardized API response format
614
+ const body = response.data;
615
+ if (body && body.success && body.data !== undefined) {
616
+ return body.data;
617
+ }
618
+ return body;
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Get baseline URLs for approved visuals (for diffing)
624
+ * @param {string} projectId - Project ID
625
+ * @param {string} apiKey - API key for authentication
626
+ * @returns {Promise<Object>} Map of "scenarioKey/captureKey" to CDN URLs
627
+ */
628
+ async function getBaselines(projectId, apiKey) {
629
+ return withRetry(async () => {
630
+ const response = await axios.get(
631
+ `${baseUrl}/v1/projects/${projectId}/baselines`,
632
+ {
633
+ headers: { Authorization: `Bearer ${apiKey}` },
634
+ timeout: 30000,
635
+ },
636
+ );
637
+ return response.data.baselines || {};
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Export visuals as JSON for the pull command
643
+ * @param {string} projectId - Project ID
644
+ * @param {Object} options - Export options
645
+ * @param {string} options.format - Export format ('json', 'csv')
646
+ * @param {string} options.status - Status filter ('approved', 'pending', 'all')
647
+ * @returns {Promise<Object>} Asset map with meta and assets
648
+ */
649
+ async function exportVisuals(projectId, options = {}) {
650
+ const { format = "json", status = "approved" } = options;
651
+ const settings = require("./config").loadSettings();
652
+ const apiKey = settings?.apiKey;
653
+
654
+ if (!apiKey) {
655
+ throw new Error("Not authenticated. Run 'reshot auth' first.");
656
+ }
657
+
658
+ return withRetry(async () => {
659
+ const response = await axios.get(
660
+ `${baseUrl}/projects/${projectId}/visuals/export`,
661
+ {
662
+ params: { format, status },
663
+ headers: { Authorization: `Bearer ${apiKey}` },
664
+ timeout: 60000,
665
+ },
666
+ );
667
+ return response.data;
668
+ });
669
+ }
670
+
671
+ /**
672
+ * Generic POST helper for API calls
673
+ * Unwraps the standardized API response { success, data } format
674
+ */
675
+ async function post(endpoint, data, options = {}) {
676
+ return withRetry(async () => {
677
+ const response = await axios.post(`${baseUrl}${endpoint}`, data, {
678
+ ...options,
679
+ timeout: options.timeout || 60000,
680
+ });
681
+ // Unwrap standardized API response format
682
+ const body = response.data;
683
+ if (body && body.success && body.data !== undefined) {
684
+ return body.data;
685
+ }
686
+ return body;
687
+ });
688
+ }
689
+
690
+ /**
691
+ * DocSync: Initialize ingestion job with manifest
692
+ */
693
+ async function initIngest(apiKey, projectId, manifest) {
694
+ return withRetry(async () => {
695
+ const response = await axios.post(
696
+ `${baseUrl}/v1/ingest/init`,
697
+ { projectId, manifest },
698
+ {
699
+ headers: { Authorization: `Bearer ${apiKey}` },
700
+ timeout: 30000,
701
+ },
702
+ );
703
+ return response.data;
704
+ });
705
+ }
706
+
707
+ /**
708
+ * DocSync: Commit ingestion job after uploads complete
709
+ */
710
+ async function commitIngest(apiKey, projectId, uploadResults, git, cli) {
711
+ return withRetry(async () => {
712
+ const response = await axios.post(
713
+ `${baseUrl}/v1/ingest/commit`,
714
+ { projectId, uploadResults, git, cli },
715
+ {
716
+ headers: { Authorization: `Bearer ${apiKey}` },
717
+ timeout: 30000,
718
+ },
719
+ );
720
+ return response.data;
721
+ });
722
+ }
723
+
724
+ /**
725
+ * DocSync: Get drift records for a project
726
+ */
727
+ async function getDrifts(apiKey, projectId, options = {}) {
728
+ return withRetry(async () => {
729
+ const params = new URLSearchParams();
730
+ if (options.status) params.set("status", options.status);
731
+ if (options.journeyKey) params.set("journeyKey", options.journeyKey);
732
+
733
+ const response = await axios.get(
734
+ `${baseUrl}/v1/projects/${projectId}/drifts?${params.toString()}`,
735
+ {
736
+ headers: { Authorization: `Bearer ${apiKey}` },
737
+ timeout: 30000,
738
+ },
739
+ );
740
+ return response.data;
741
+ });
742
+ }
743
+
744
+ /**
745
+ * DocSync: Get sync jobs for a project
746
+ */
747
+ async function getSyncJobs(apiKey, projectId, options = {}) {
748
+ return withRetry(async () => {
749
+ const response = await axios.post(
750
+ `${baseUrl}/v1/projects/${projectId}/sync-jobs`,
751
+ {
752
+ limit: options.limit || 10,
753
+ status: options.status,
754
+ },
755
+ {
756
+ headers: { Authorization: `Bearer ${apiKey}` },
757
+ timeout: 30000,
758
+ },
759
+ );
760
+ return response.data;
761
+ });
762
+ }
763
+
764
+ /**
765
+ * DocSync: Perform action on a drift record
766
+ */
767
+ async function driftAction(apiKey, projectId, driftId, action, options = {}) {
768
+ return withRetry(async () => {
769
+ const response = await axios.post(
770
+ `${baseUrl}/v1/projects/${projectId}/drifts/${driftId}/action`,
771
+ {
772
+ action,
773
+ comment: options.comment,
774
+ reason: options.reason,
775
+ },
776
+ {
777
+ headers: { Authorization: `Bearer ${apiKey}` },
778
+ timeout: 30000,
779
+ },
780
+ );
781
+ return response.data;
782
+ });
783
+ }
784
+
785
+ module.exports = {
786
+ getProjects,
787
+ getProject,
788
+ getVisuals,
789
+ getVisualKeys,
790
+ publishAsset,
791
+ publishAssetsV1,
792
+ publishDocs,
793
+ getReviewQueue,
794
+ getProjectConfig,
795
+ postChangelogDrafts,
796
+ getApiBaseUrl,
797
+ syncPushAssets,
798
+ getSyncStatus,
799
+ // New transactional flow
800
+ signAssets,
801
+ uploadToPresignedUrl,
802
+ publishTransactional,
803
+ checkExistingHashes,
804
+ // Diffing support
805
+ getBaselines,
806
+ // Export support
807
+ exportVisuals,
808
+ // DocSync
809
+ post,
810
+ initIngest,
811
+ commitIngest,
812
+ getDrifts,
813
+ getSyncJobs,
814
+ driftAction,
815
+ };