@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,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
|
+
};
|