@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,1240 @@
1
+ // config.js - Configuration file helpers
2
+ const fs = require("fs-extra");
3
+ const path = require("path");
4
+
5
+ // Import new modules for enhanced functionality
6
+ const {
7
+ validateTemplate,
8
+ getTemplatePresets,
9
+ TEMPLATE_PRESETS,
10
+ } = require("./output-path-template");
11
+ const {
12
+ validateViewport,
13
+ resolveViewport,
14
+ getAllViewportPresets,
15
+ getAllCropPresets,
16
+ VIEWPORT_PRESETS,
17
+ } = require("./viewport-presets");
18
+ const {
19
+ isStandaloneMode,
20
+ getAvailableFeatures,
21
+ getConfigDefaults,
22
+ validateCaptureRequirements,
23
+ } = require("./standalone-mode");
24
+
25
+ const SETTINGS_DIR = ".reshot";
26
+ const SETTINGS_PATH = path.join(process.cwd(), SETTINGS_DIR, "settings.json");
27
+
28
+ /**
29
+ * Check if an error indicates the API key is invalid and re-auth is needed
30
+ * @param {Error|Object} error - The error from API call
31
+ * @returns {boolean}
32
+ */
33
+ function isAuthError(error) {
34
+ if (!error) return false;
35
+
36
+ // Check for axios response errors
37
+ const status = error.response?.status;
38
+ if (status === 401 || status === 403) return true;
39
+
40
+ // Check error message for auth-related keywords
41
+ const message = (
42
+ error.message ||
43
+ error.response?.data?.error ||
44
+ ""
45
+ ).toLowerCase();
46
+ return (
47
+ message.includes("invalid api key") ||
48
+ message.includes("api key required") ||
49
+ message.includes("unauthorized") ||
50
+ message.includes("authentication") ||
51
+ message.includes("not authenticated")
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Create an auth error response object for the UI
57
+ * @param {string} message - Error message
58
+ * @returns {Object}
59
+ */
60
+ function createAuthErrorResponse(message) {
61
+ return {
62
+ error: message,
63
+ authRequired: true,
64
+ code: "AUTH_REQUIRED",
65
+ };
66
+ }
67
+ const CONFIG_PATH = path.join(process.cwd(), "docsync.config.json");
68
+ const WORKSPACE_PATH = path.join(process.cwd(), SETTINGS_DIR, "workspace.json");
69
+
70
+ /**
71
+ * Read settings file
72
+ * @returns {Object} Settings object with projectId
73
+ */
74
+ function readSettings() {
75
+ if (!fs.existsSync(SETTINGS_PATH)) {
76
+ throw new Error(
77
+ "Reshot is not initialized in this directory. Run `reshot init` after authenticating."
78
+ );
79
+ }
80
+ return fs.readJSONSync(SETTINGS_PATH);
81
+ }
82
+
83
+ /**
84
+ * Write settings file
85
+ * @param {Object} settings - Settings object to write
86
+ */
87
+ function writeSettings(settings) {
88
+ const settingsDir = path.dirname(SETTINGS_PATH);
89
+ fs.ensureDirSync(settingsDir);
90
+ fs.writeJSONSync(SETTINGS_PATH, settings, { spaces: 2 });
91
+ }
92
+
93
+ // ===== WORKSPACE MANAGEMENT =====
94
+
95
+ /**
96
+ * Default workspace structure
97
+ * A workspace groups multiple scenarios with shared variant dimensions
98
+ */
99
+ const DEFAULT_WORKSPACE = {
100
+ name: "Default Workspace",
101
+ description: "",
102
+ // Common variant dimensions that apply to all scenarios in this workspace
103
+ variants: {
104
+ dimensions: {
105
+ // Example: locale, role, theme dimensions
106
+ },
107
+ presets: {
108
+ // Example: commonly used variant combinations
109
+ },
110
+ },
111
+ // Scenarios included in this workspace (by key)
112
+ scenarios: [],
113
+ // Metadata
114
+ createdAt: null,
115
+ updatedAt: null,
116
+ };
117
+
118
+ /**
119
+ * Check if workspace file exists
120
+ * @returns {boolean}
121
+ */
122
+ function workspaceExists() {
123
+ return fs.existsSync(WORKSPACE_PATH);
124
+ }
125
+
126
+ /**
127
+ * Read workspace file
128
+ * @returns {Object} Workspace configuration
129
+ */
130
+ function readWorkspace() {
131
+ if (!fs.existsSync(WORKSPACE_PATH)) {
132
+ return null;
133
+ }
134
+ return fs.readJSONSync(WORKSPACE_PATH);
135
+ }
136
+
137
+ /**
138
+ * Write workspace file
139
+ * @param {Object} workspace - Workspace object to write
140
+ */
141
+ function writeWorkspace(workspace) {
142
+ const settingsDir = path.dirname(WORKSPACE_PATH);
143
+ fs.ensureDirSync(settingsDir);
144
+ workspace.updatedAt = new Date().toISOString();
145
+ fs.writeJSONSync(WORKSPACE_PATH, workspace, { spaces: 2 });
146
+ }
147
+
148
+ /**
149
+ * Create a new workspace
150
+ * @param {Object} options - Workspace options
151
+ * @param {string} options.name - Workspace name
152
+ * @param {string} [options.description] - Workspace description
153
+ * @param {Object} [options.variants] - Variant configuration
154
+ * @returns {Object} Created workspace
155
+ */
156
+ function createWorkspace(options = {}) {
157
+ const workspace = {
158
+ ...DEFAULT_WORKSPACE,
159
+ name: options.name || "Default Workspace",
160
+ description: options.description || "",
161
+ variants: options.variants || DEFAULT_WORKSPACE.variants,
162
+ scenarios: [],
163
+ createdAt: new Date().toISOString(),
164
+ updatedAt: new Date().toISOString(),
165
+ };
166
+ writeWorkspace(workspace);
167
+ return workspace;
168
+ }
169
+
170
+ /**
171
+ * Add a scenario to the workspace
172
+ * @param {string} scenarioKey - Scenario key to add
173
+ * @returns {Object} Updated workspace
174
+ */
175
+ function addScenarioToWorkspace(scenarioKey) {
176
+ let workspace = readWorkspace();
177
+ if (!workspace) {
178
+ workspace = createWorkspace();
179
+ }
180
+
181
+ // Ensure scenarios is an array
182
+ if (!Array.isArray(workspace.scenarios)) {
183
+ workspace.scenarios = [];
184
+ }
185
+
186
+ if (!workspace.scenarios.includes(scenarioKey)) {
187
+ workspace.scenarios.push(scenarioKey);
188
+ writeWorkspace(workspace);
189
+ }
190
+ return workspace;
191
+ }
192
+
193
+ /**
194
+ * Remove a scenario from the workspace
195
+ * @param {string} scenarioKey - Scenario key to remove
196
+ * @returns {Object} Updated workspace
197
+ */
198
+ function removeScenarioFromWorkspace(scenarioKey) {
199
+ const workspace = readWorkspace();
200
+ if (!workspace) {
201
+ return null;
202
+ }
203
+
204
+ // Ensure scenarios is an array
205
+ if (!Array.isArray(workspace.scenarios)) {
206
+ workspace.scenarios = [];
207
+ writeWorkspace(workspace);
208
+ return workspace;
209
+ }
210
+
211
+ const index = workspace.scenarios.indexOf(scenarioKey);
212
+ if (index !== -1) {
213
+ workspace.scenarios.splice(index, 1);
214
+ writeWorkspace(workspace);
215
+ }
216
+ return workspace;
217
+ }
218
+
219
+ /**
220
+ * Update workspace variants configuration
221
+ * @param {Object} variants - New variants configuration
222
+ * @returns {Object} Updated workspace
223
+ */
224
+ function updateWorkspaceVariants(variants) {
225
+ let workspace = readWorkspace();
226
+ if (!workspace) {
227
+ workspace = createWorkspace();
228
+ }
229
+
230
+ workspace.variants = variants;
231
+ writeWorkspace(workspace);
232
+ return workspace;
233
+ }
234
+
235
+ /**
236
+ * Get workspace with resolved scenarios
237
+ * @returns {Object|null} Workspace with scenario details from config
238
+ */
239
+ function getWorkspaceWithScenarios() {
240
+ const workspace = readWorkspace();
241
+ if (!workspace) {
242
+ return null;
243
+ }
244
+
245
+ let docSyncConfig = null;
246
+ try {
247
+ docSyncConfig = readConfig();
248
+ } catch (e) {
249
+ // Config doesn't exist
250
+ }
251
+
252
+ const allScenarios = docSyncConfig?.scenarios || [];
253
+
254
+ // Ensure workspace.scenarios is always an array to prevent .map errors
255
+ const workspaceScenarioKeys = Array.isArray(workspace.scenarios)
256
+ ? workspace.scenarios
257
+ : [];
258
+
259
+ const workspaceScenarios = workspaceScenarioKeys
260
+ .map((key) => allScenarios.find((s) => s.key === key))
261
+ .filter(Boolean);
262
+
263
+ return {
264
+ ...workspace,
265
+ scenarios: workspaceScenarioKeys, // Ensure scenarios is always an array
266
+ resolvedScenarios: workspaceScenarios,
267
+ allScenarios: allScenarios,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Read config file
273
+ * @returns {Object} Reshot configuration
274
+ */
275
+ function readConfig() {
276
+ if (!fs.existsSync(CONFIG_PATH)) {
277
+ throw new Error(
278
+ `Config file not found at ${CONFIG_PATH}. Run \`reshot init\` to create one.`
279
+ );
280
+ }
281
+
282
+ const config = fs.readJSONSync(CONFIG_PATH);
283
+
284
+ // Validate required fields
285
+ if (!config.scenarios || !Array.isArray(config.scenarios)) {
286
+ throw new Error('Config must have a "scenarios" array');
287
+ }
288
+
289
+ const validActions = [
290
+ "click",
291
+ "type",
292
+ "input",
293
+ "hover",
294
+ "wait",
295
+ "waitForSelector",
296
+ "screenshot",
297
+ "goto",
298
+ "scroll",
299
+ "select",
300
+ "keyboard",
301
+ "clip",
302
+ "gif", // Looping GIF capture
303
+ "video", // Video clip capture
304
+ ];
305
+
306
+ // Valid output formats
307
+ const validOutputFormats = [
308
+ "png", // Static screenshot (default)
309
+ "gif", // Looping GIF (primary for animations)
310
+ "mp4", // Video clip
311
+ "step-by-step-images", // Legacy: individual step screenshots
312
+ "summary-video", // Legacy: combined video of all steps
313
+ ];
314
+
315
+ for (const scenario of config.scenarios) {
316
+ if (!scenario.name) {
317
+ throw new Error('Each scenario must have a "name" field');
318
+ }
319
+ if (!scenario.key) {
320
+ throw new Error(`Scenario "${scenario.name}" must have a "key" field`);
321
+ }
322
+ if (!/^[a-z0-9-]+$/i.test(scenario.key)) {
323
+ throw new Error(
324
+ `Scenario "${scenario.name}" has invalid key "${scenario.key}". Keys must be alphanumeric with hyphens only.`
325
+ );
326
+ }
327
+ if (!scenario.url) {
328
+ throw new Error(`Scenario "${scenario.name}" must have a "url" field`);
329
+ }
330
+ if (!scenario.steps || !Array.isArray(scenario.steps)) {
331
+ throw new Error(`Scenario "${scenario.name}" must have a "steps" array`);
332
+ }
333
+
334
+ // Validate each step
335
+ for (let i = 0; i < scenario.steps.length; i++) {
336
+ const step = scenario.steps[i];
337
+ const stepNum = i + 1;
338
+
339
+ if (!step.action) {
340
+ throw new Error(
341
+ `Scenario "${scenario.name}" step ${stepNum} must have an "action" field`
342
+ );
343
+ }
344
+
345
+ if (!validActions.includes(step.action)) {
346
+ throw new Error(
347
+ `Scenario "${scenario.name}" step ${stepNum} has invalid action "${
348
+ step.action
349
+ }". Valid actions: ${validActions.join(", ")}`
350
+ );
351
+ }
352
+
353
+ // Validate selector for actions that need it
354
+ const needsSelector = [
355
+ "click",
356
+ "type",
357
+ "input",
358
+ "hover",
359
+ "waitForSelector",
360
+ "scroll",
361
+ "select",
362
+ ];
363
+ if (needsSelector.includes(step.action) && !step.selector) {
364
+ throw new Error(
365
+ `Scenario "${scenario.name}" step ${stepNum} (${step.action}) requires a "selector" field`
366
+ );
367
+ }
368
+
369
+ // Validate text for type/input actions
370
+ if (
371
+ (step.action === "type" || step.action === "input") &&
372
+ step.text === undefined
373
+ ) {
374
+ throw new Error(
375
+ `Scenario "${scenario.name}" step ${stepNum} (${step.action}) requires a "text" field`
376
+ );
377
+ }
378
+
379
+ // Validate step-level privacy override (must be object or undefined)
380
+ if (step.privacy !== undefined) {
381
+ if (typeof step.privacy !== "object" || step.privacy === null || Array.isArray(step.privacy)) {
382
+ throw new Error(
383
+ `Scenario "${scenario.name}" step ${stepNum}: privacy must be an object`
384
+ );
385
+ }
386
+ }
387
+
388
+ // Validate step-level style override (must be object or undefined)
389
+ if (step.style !== undefined) {
390
+ if (typeof step.style !== "object" || step.style === null || Array.isArray(step.style)) {
391
+ throw new Error(
392
+ `Scenario "${scenario.name}" step ${stepNum}: style must be an object`
393
+ );
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ // Validate optional privacy block
400
+ if (config.privacy !== undefined) {
401
+ if (typeof config.privacy !== "object" || config.privacy === null || Array.isArray(config.privacy)) {
402
+ throw new Error("privacy must be an object");
403
+ }
404
+ if (config.privacy.method !== undefined && !["redact", "blur", "hide", "remove"].includes(config.privacy.method)) {
405
+ throw new Error('privacy.method must be one of: redact, blur, hide, remove');
406
+ }
407
+ if (config.privacy.blurRadius !== undefined) {
408
+ if (typeof config.privacy.blurRadius !== "number" || config.privacy.blurRadius < 1 || config.privacy.blurRadius > 100) {
409
+ throw new Error("privacy.blurRadius must be a number between 1 and 100");
410
+ }
411
+ }
412
+ if (config.privacy.selectors !== undefined && !Array.isArray(config.privacy.selectors)) {
413
+ throw new Error("privacy.selectors must be an array");
414
+ }
415
+ // Validate individual selector entries
416
+ if (Array.isArray(config.privacy.selectors)) {
417
+ for (let i = 0; i < config.privacy.selectors.length; i++) {
418
+ const entry = config.privacy.selectors[i];
419
+ if (typeof entry === "string") {
420
+ if (!entry.trim()) {
421
+ throw new Error(`privacy.selectors[${i}] is empty`);
422
+ }
423
+ } else if (entry && typeof entry === "object") {
424
+ if (!entry.selector || typeof entry.selector !== "string" || !entry.selector.trim()) {
425
+ throw new Error(`privacy.selectors[${i}].selector must be a non-empty string`);
426
+ }
427
+ if (entry.method !== undefined && !["redact", "blur", "hide", "remove"].includes(entry.method)) {
428
+ throw new Error(`privacy.selectors[${i}].method must be one of: redact, blur, hide, remove`);
429
+ }
430
+ } else {
431
+ throw new Error(`privacy.selectors[${i}] must be a string or { selector, method?, blurRadius? }`);
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ // Validate optional style block
438
+ if (config.style !== undefined) {
439
+ if (typeof config.style !== "object" || config.style === null || Array.isArray(config.style)) {
440
+ throw new Error("style must be an object");
441
+ }
442
+ if (config.style.frame !== undefined && !["none", "macos", "windows"].includes(config.style.frame)) {
443
+ throw new Error('style.frame must be one of: none, macos, windows');
444
+ }
445
+ if (config.style.shadow !== undefined && !["none", "small", "medium", "large"].includes(config.style.shadow)) {
446
+ throw new Error('style.shadow must be one of: none, small, medium, large');
447
+ }
448
+ if (config.style.padding !== undefined) {
449
+ if (typeof config.style.padding !== "number" || config.style.padding < 0 || config.style.padding > 200) {
450
+ throw new Error("style.padding must be a number between 0 and 200");
451
+ }
452
+ }
453
+ if (config.style.borderRadius !== undefined) {
454
+ if (typeof config.style.borderRadius !== "number" || config.style.borderRadius < 0 || config.style.borderRadius > 100) {
455
+ throw new Error("style.borderRadius must be a number between 0 and 100");
456
+ }
457
+ }
458
+ if (config.style.background !== undefined) {
459
+ if (typeof config.style.background !== "string") {
460
+ throw new Error("style.background must be a string");
461
+ }
462
+ const bg = config.style.background;
463
+ if (bg !== "transparent") {
464
+ const isHex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(bg);
465
+ const isGradient = bg.startsWith("linear-gradient(");
466
+ if (!isHex && !isGradient) {
467
+ throw new Error('style.background must be "transparent", a hex color, or a linear-gradient()');
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // Validate optional docs block
474
+ if (config.docs !== undefined) {
475
+ if (
476
+ typeof config.docs !== "object" ||
477
+ config.docs === null ||
478
+ Array.isArray(config.docs)
479
+ ) {
480
+ throw new Error("docs must be an object");
481
+ }
482
+ if (
483
+ config.docs.root !== undefined &&
484
+ typeof config.docs.root !== "string"
485
+ ) {
486
+ throw new Error("docs.root must be a string");
487
+ }
488
+ if (
489
+ config.docs.include !== undefined &&
490
+ !Array.isArray(config.docs.include)
491
+ ) {
492
+ throw new Error("docs.include must be an array of strings");
493
+ }
494
+ if (
495
+ config.docs.exclude !== undefined &&
496
+ !Array.isArray(config.docs.exclude)
497
+ ) {
498
+ throw new Error("docs.exclude must be an array of strings");
499
+ }
500
+ }
501
+
502
+ return config;
503
+ }
504
+
505
+ /**
506
+ * Read docsync.config.json with DocSync-specific configuration
507
+ * Returns the full config including the documentation block for ingestion
508
+ * @returns {Object} DocSync configuration
509
+ */
510
+ function readDocSyncConfig() {
511
+ if (!fs.existsSync(CONFIG_PATH)) {
512
+ throw new Error(
513
+ `Config file not found at ${CONFIG_PATH}. Run \`reshot init\` to create one.`
514
+ );
515
+ }
516
+
517
+ const config = fs.readJSONSync(CONFIG_PATH);
518
+
519
+ // Validate documentation block if present
520
+ if (config.documentation) {
521
+ const doc = config.documentation;
522
+
523
+ // Validate required fields
524
+ if (!doc.strategy) {
525
+ throw new Error('documentation.strategy is required (git_pr or external_host)');
526
+ }
527
+
528
+ if (!['git_pr', 'external_host'].includes(doc.strategy)) {
529
+ throw new Error('documentation.strategy must be "git_pr" or "external_host"');
530
+ }
531
+
532
+ // Validate optional fields
533
+ if (doc.assetFormat && !['cdn_link', 'markdown'].includes(doc.assetFormat)) {
534
+ throw new Error('documentation.assetFormat must be "cdn_link" or "markdown"');
535
+ }
536
+
537
+ if (doc.include && !Array.isArray(doc.include)) {
538
+ throw new Error('documentation.include must be an array of glob patterns');
539
+ }
540
+
541
+ if (doc.exclude && !Array.isArray(doc.exclude)) {
542
+ throw new Error('documentation.exclude must be an array of glob patterns');
543
+ }
544
+
545
+ if (doc.mappings && typeof doc.mappings !== 'object') {
546
+ throw new Error('documentation.mappings must be an object');
547
+ }
548
+ }
549
+
550
+ return config;
551
+ }
552
+
553
+ /**
554
+ * Write config file
555
+ * @param {Object} config - Config object to write
556
+ */
557
+ function writeConfig(config) {
558
+ fs.writeJSONSync(CONFIG_PATH, config, { spaces: 2 });
559
+ }
560
+
561
+ /**
562
+ * Check if config file exists
563
+ * @returns {boolean}
564
+ */
565
+ function configExists() {
566
+ return fs.existsSync(CONFIG_PATH);
567
+ }
568
+
569
+ // ===== CAPTURE CONFIGURATION =====
570
+
571
+ /**
572
+ * Default capture configuration
573
+ */
574
+ const DEFAULT_CAPTURE_CONFIG = {
575
+ retryOnError: 2,
576
+ retryDelay: 1000,
577
+ readyTimeout: 15000,
578
+ scenarioTimeout: 60000,
579
+ errorSelectors: ["[data-testid='page-error']", "[data-error-type]"],
580
+ errorHeuristics: true,
581
+ contentVerification: false,
582
+ preflightCheck: true,
583
+ authPatterns: [],
584
+ };
585
+
586
+ /**
587
+ * Get capture configuration with sensible defaults
588
+ * Merges global capture config with per-scenario overrides
589
+ * @param {Object} [scenarioOverrides] - Per-scenario capture config overrides
590
+ * @returns {Object} Merged capture config
591
+ */
592
+ function getCaptureConfig(scenarioOverrides = {}) {
593
+ let globalConfig = {};
594
+ try {
595
+ const config = readConfig();
596
+ globalConfig = config.capture || {};
597
+ } catch (e) {
598
+ // Config doesn't exist, use defaults
599
+ }
600
+
601
+ // Filter out undefined values so they don't overwrite defaults
602
+ const cleanOverrides = Object.fromEntries(
603
+ Object.entries(scenarioOverrides).filter(([, v]) => v !== undefined)
604
+ );
605
+ const cleanGlobal = Object.fromEntries(
606
+ Object.entries(globalConfig).filter(([, v]) => v !== undefined)
607
+ );
608
+
609
+ const merged = {
610
+ ...DEFAULT_CAPTURE_CONFIG,
611
+ ...cleanGlobal,
612
+ ...cleanOverrides,
613
+ };
614
+
615
+ // Validate bounds
616
+ if (typeof merged.retryOnError === "number") {
617
+ merged.retryOnError = Math.max(0, Math.min(merged.retryOnError, 5));
618
+ }
619
+ if (typeof merged.retryDelay === "number") {
620
+ merged.retryDelay = Math.max(500, Math.min(merged.retryDelay, 30000));
621
+ }
622
+ if (typeof merged.readyTimeout === "number") {
623
+ merged.readyTimeout = Math.max(1000, Math.min(merged.readyTimeout, 60000));
624
+ }
625
+ if (typeof merged.scenarioTimeout === "number") {
626
+ merged.scenarioTimeout = Math.max(
627
+ 5000,
628
+ Math.min(merged.scenarioTimeout, 300000)
629
+ );
630
+ }
631
+
632
+ // Ensure errorSelectors is always an array
633
+ if (!Array.isArray(merged.errorSelectors)) {
634
+ merged.errorSelectors = DEFAULT_CAPTURE_CONFIG.errorSelectors;
635
+ }
636
+
637
+ return merged;
638
+ }
639
+
640
+ // ===== PRIVACY CONFIGURATION =====
641
+
642
+ /**
643
+ * Default privacy configuration
644
+ */
645
+ const DEFAULT_PRIVACY_CONFIG = {
646
+ enabled: true,
647
+ method: "redact",
648
+ blurRadius: 8,
649
+ selectors: [],
650
+ };
651
+
652
+ const VALID_PRIVACY_METHODS = ["redact", "blur", "hide", "remove"];
653
+
654
+ /**
655
+ * Get privacy configuration with sensible defaults
656
+ * Merges global privacy config with per-scenario overrides.
657
+ * Selectors are ADDITIVE (union). method/blurRadius are overridden.
658
+ * @param {Object} [scenarioOverrides] - Per-scenario privacy config overrides
659
+ * @returns {Object} Merged privacy config
660
+ */
661
+ function getPrivacyConfig(scenarioOverrides = {}) {
662
+ let globalConfig = {};
663
+ try {
664
+ const config = readConfig();
665
+ globalConfig = config.privacy || {};
666
+ } catch (e) {
667
+ // Config doesn't exist, use defaults
668
+ }
669
+
670
+ const merged = {
671
+ enabled: scenarioOverrides.enabled !== undefined
672
+ ? scenarioOverrides.enabled
673
+ : globalConfig.enabled !== undefined
674
+ ? globalConfig.enabled
675
+ : DEFAULT_PRIVACY_CONFIG.enabled,
676
+ method: scenarioOverrides.method || globalConfig.method || DEFAULT_PRIVACY_CONFIG.method,
677
+ blurRadius: scenarioOverrides.blurRadius || globalConfig.blurRadius || DEFAULT_PRIVACY_CONFIG.blurRadius,
678
+ // Selectors are additive (union of global + scenario)
679
+ selectors: [
680
+ ...(globalConfig.selectors || []),
681
+ ...(scenarioOverrides.selectors || []),
682
+ ],
683
+ };
684
+
685
+ // Validate method
686
+ if (!VALID_PRIVACY_METHODS.includes(merged.method)) {
687
+ merged.method = DEFAULT_PRIVACY_CONFIG.method;
688
+ }
689
+
690
+ // Validate blurRadius bounds
691
+ if (typeof merged.blurRadius === "number") {
692
+ merged.blurRadius = Math.max(1, Math.min(merged.blurRadius, 100));
693
+ }
694
+
695
+ return merged;
696
+ }
697
+
698
+ // ===== STYLE CONFIGURATION =====
699
+
700
+ /**
701
+ * Default style configuration
702
+ */
703
+ const DEFAULT_STYLE_CONFIG = {
704
+ enabled: true,
705
+ frame: "none",
706
+ shadow: "medium",
707
+ padding: 40,
708
+ background: "transparent",
709
+ borderRadius: 0,
710
+ };
711
+
712
+ const VALID_FRAMES = ["none", "macos", "windows"];
713
+ const VALID_SHADOWS = ["none", "small", "medium", "large"];
714
+
715
+ /**
716
+ * Get style configuration with sensible defaults
717
+ * Merges global style config with per-scenario overrides (flat replace, not additive).
718
+ * @param {Object} [scenarioOverrides] - Per-scenario style config overrides
719
+ * @returns {Object} Merged style config
720
+ */
721
+ function getStyleConfig(scenarioOverrides = {}) {
722
+ let globalConfig = {};
723
+ try {
724
+ const config = readConfig();
725
+ globalConfig = config.style || {};
726
+ } catch (e) {
727
+ // Config doesn't exist, use defaults
728
+ }
729
+
730
+ // Filter out undefined values
731
+ const cleanOverrides = Object.fromEntries(
732
+ Object.entries(scenarioOverrides).filter(([, v]) => v !== undefined)
733
+ );
734
+ const cleanGlobal = Object.fromEntries(
735
+ Object.entries(globalConfig).filter(([, v]) => v !== undefined)
736
+ );
737
+
738
+ const merged = {
739
+ ...DEFAULT_STYLE_CONFIG,
740
+ ...cleanGlobal,
741
+ ...cleanOverrides,
742
+ };
743
+
744
+ // Validate frame
745
+ if (!VALID_FRAMES.includes(merged.frame)) {
746
+ merged.frame = DEFAULT_STYLE_CONFIG.frame;
747
+ }
748
+
749
+ // Validate shadow
750
+ if (!VALID_SHADOWS.includes(merged.shadow)) {
751
+ merged.shadow = DEFAULT_STYLE_CONFIG.shadow;
752
+ }
753
+
754
+ // Validate bounds
755
+ if (typeof merged.padding === "number") {
756
+ merged.padding = Math.max(0, Math.min(merged.padding, 200));
757
+ }
758
+ if (typeof merged.borderRadius === "number") {
759
+ merged.borderRadius = Math.max(0, Math.min(merged.borderRadius, 100));
760
+ }
761
+
762
+ return merged;
763
+ }
764
+
765
+ // ===== DIFFING CONFIGURATION =====
766
+
767
+ /**
768
+ * Get diffing configuration with defaults
769
+ * Diffing is ENABLED by default for local version-to-version comparison
770
+ * @returns {Object} Diffing config { enabled, threshold, includeAA }
771
+ */
772
+ function getDiffingConfig() {
773
+ try {
774
+ const config = readConfig();
775
+ return {
776
+ // Default to TRUE - always diff unless explicitly disabled
777
+ enabled: config.diffing?.enabled ?? true,
778
+ threshold: config.diffing?.threshold ?? 0.1,
779
+ includeAA: config.diffing?.includeAA ?? false,
780
+ };
781
+ } catch (e) {
782
+ // Return defaults if config doesn't exist - diffing ON by default
783
+ return {
784
+ enabled: true,
785
+ threshold: 0.1,
786
+ includeAA: false,
787
+ };
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Update diffing configuration
793
+ * @param {Object} diffingConfig - New diffing config (partial updates supported)
794
+ */
795
+ function updateDiffingConfig(diffingConfig) {
796
+ const config = readConfig();
797
+ config.diffing = {
798
+ enabled: config.diffing?.enabled ?? false,
799
+ threshold: config.diffing?.threshold ?? 0.1,
800
+ includeAA: config.diffing?.includeAA ?? false,
801
+ ...diffingConfig,
802
+ };
803
+ writeConfig(config);
804
+ return config.diffing;
805
+ }
806
+
807
+ /**
808
+ * Initialize project by fetching config from platform
809
+ * This is the core logic shared between CLI init command and UI init endpoint
810
+ * @param {string} projectId - Project ID to initialize
811
+ * @param {string} apiKey - API key for authentication
812
+ * @param {Object} options - Options
813
+ * @param {boolean} options.overwrite - Whether to overwrite existing config (default: false)
814
+ * @returns {Promise<Object>} The initialized config
815
+ */
816
+ async function initializeProject(projectId, apiKey, options = {}) {
817
+ const apiClient = require("./api-client");
818
+ const { overwrite = false } = options;
819
+
820
+ if (!projectId || !apiKey) {
821
+ throw new Error("projectId and apiKey are required");
822
+ }
823
+
824
+ // Check if config exists and overwrite is false
825
+ if (configExists() && !overwrite) {
826
+ throw new Error("Config already exists. Set overwrite=true to replace it.");
827
+ }
828
+
829
+ let blueprint = null;
830
+ try {
831
+ blueprint = await apiClient.getProjectConfig(projectId, apiKey);
832
+ } catch (error) {
833
+ // If fetch fails, use boilerplate
834
+ const BOILERPLATE_CONFIG = {
835
+ baseUrl: "https://example.com",
836
+ assetDir: ".reshot/output",
837
+ concurrency: 2,
838
+ defaultWaitUntil: "networkidle",
839
+ viewport: { width: 1280, height: 720 },
840
+ timeout: 45000,
841
+ headless: true,
842
+ contexts: {
843
+ default: { name: "default", data: {} },
844
+ },
845
+ scenarios: [], // Start with empty scenarios - user will record their own
846
+ _metadata: {
847
+ projectId,
848
+ projectName: "Unknown Project",
849
+ generatedAt: new Date().toISOString(),
850
+ visualCount: 1,
851
+ contextCount: 1,
852
+ features: {
853
+ visuals: true,
854
+ docs: false,
855
+ changelog: true,
856
+ },
857
+ },
858
+ };
859
+ blueprint = BOILERPLATE_CONFIG;
860
+ }
861
+
862
+ // Write config
863
+ writeConfig(blueprint);
864
+
865
+ // Update settings
866
+ let settings;
867
+ try {
868
+ settings = readSettings();
869
+ } catch (error) {
870
+ // Create new settings if they don't exist
871
+ settings = { projectId, apiKey };
872
+ }
873
+
874
+ const updatedSettings = {
875
+ ...settings,
876
+ projectId,
877
+ apiKey,
878
+ projectName:
879
+ blueprint._metadata?.projectName || settings.projectName || null,
880
+ lastSyncedAt: new Date().toISOString(),
881
+ };
882
+ writeSettings(updatedSettings);
883
+
884
+ return blueprint;
885
+ }
886
+
887
+ /**
888
+ * Get output configuration with enhanced features
889
+ * @returns {Object} Output config with template, viewports, and crop settings
890
+ */
891
+ function getOutputConfig() {
892
+ try {
893
+ const config = readConfig();
894
+ return {
895
+ template: config.output?.template || TEMPLATE_PRESETS.default,
896
+ templatePresets: getTemplatePresets(),
897
+ viewport: config.viewport || { width: 1280, height: 720 },
898
+ viewportPresets: config.viewportPresets || {},
899
+ crop: config.output?.crop || null,
900
+ cropPresets: getAllCropPresets(),
901
+ };
902
+ } catch (e) {
903
+ return {
904
+ template: TEMPLATE_PRESETS.default,
905
+ templatePresets: getTemplatePresets(),
906
+ viewport: { width: 1280, height: 720 },
907
+ viewportPresets: {},
908
+ crop: null,
909
+ cropPresets: getAllCropPresets(),
910
+ };
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Update output configuration
916
+ * @param {Object} outputConfig - New output configuration (partial update)
917
+ * @returns {Object} Updated configuration
918
+ */
919
+ function updateOutputConfig(outputConfig) {
920
+ const config = readConfig();
921
+
922
+ // Validate template if provided
923
+ if (outputConfig.template) {
924
+ const validation = validateTemplate(outputConfig.template);
925
+ if (!validation.valid) {
926
+ throw new Error(`Invalid output template: ${validation.error}`);
927
+ }
928
+ }
929
+
930
+ // Validate viewport if provided
931
+ if (outputConfig.viewport) {
932
+ const resolved = resolveViewport(outputConfig.viewport);
933
+ const validation = validateViewport(resolved);
934
+ if (!validation.valid) {
935
+ throw new Error(`Invalid viewport: ${validation.error}`);
936
+ }
937
+ }
938
+
939
+ // Merge output config
940
+ config.output = {
941
+ ...config.output,
942
+ ...outputConfig,
943
+ };
944
+
945
+ // Update viewport at top level if provided
946
+ if (outputConfig.viewport) {
947
+ config.viewport = resolveViewport(outputConfig.viewport);
948
+ }
949
+
950
+ writeConfig(config);
951
+ return config;
952
+ }
953
+
954
+ /**
955
+ * Get available viewport presets (built-in + custom)
956
+ * @returns {Object} Viewport presets
957
+ */
958
+ function getViewportPresetsConfig() {
959
+ try {
960
+ const config = readConfig();
961
+ const builtIn = getAllViewportPresets();
962
+ const custom = config.viewportPresets || {};
963
+
964
+ return {
965
+ builtIn,
966
+ custom,
967
+ all: { ...builtIn, ...custom },
968
+ };
969
+ } catch (e) {
970
+ return {
971
+ builtIn: getAllViewportPresets(),
972
+ custom: {},
973
+ all: getAllViewportPresets(),
974
+ };
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Add or update a custom viewport preset
980
+ * @param {string} key - Preset key
981
+ * @param {Object} preset - Preset configuration
982
+ * @returns {Object} Updated config
983
+ */
984
+ function saveViewportPreset(key, preset) {
985
+ const resolved = resolveViewport(preset);
986
+ const validation = validateViewport(resolved);
987
+ if (!validation.valid) {
988
+ throw new Error(`Invalid viewport preset: ${validation.error}`);
989
+ }
990
+
991
+ const config = readConfig();
992
+ config.viewportPresets = config.viewportPresets || {};
993
+ config.viewportPresets[key] = {
994
+ ...preset,
995
+ ...resolved,
996
+ category: "custom",
997
+ };
998
+
999
+ writeConfig(config);
1000
+ return config;
1001
+ }
1002
+
1003
+ /**
1004
+ * Delete a custom viewport preset
1005
+ * @param {string} key - Preset key to delete
1006
+ * @returns {Object} Updated config
1007
+ */
1008
+ function deleteViewportPreset(key) {
1009
+ const config = readConfig();
1010
+ if (config.viewportPresets && config.viewportPresets[key]) {
1011
+ delete config.viewportPresets[key];
1012
+ writeConfig(config);
1013
+ }
1014
+ return config;
1015
+ }
1016
+
1017
+ /**
1018
+ * Check CLI mode and features
1019
+ * @returns {Object} Mode info and available features
1020
+ */
1021
+ function getModeInfo() {
1022
+ let settings = null;
1023
+ try {
1024
+ settings = readSettings();
1025
+ } catch (e) {
1026
+ // No settings file
1027
+ }
1028
+
1029
+ return {
1030
+ isStandalone: isStandaloneMode(settings),
1031
+ features: getAvailableFeatures(settings),
1032
+ settings: settings
1033
+ ? {
1034
+ mode: settings.mode || (settings.apiKey ? "connected" : "standalone"),
1035
+ projectName: settings.projectName,
1036
+ projectId: settings.projectId,
1037
+ hasApiKey: !!settings.apiKey,
1038
+ }
1039
+ : null,
1040
+ };
1041
+ }
1042
+
1043
+ /**
1044
+ * Validate full configuration for capture readiness
1045
+ * @returns {Object} Validation result
1046
+ */
1047
+ function validateConfig() {
1048
+ try {
1049
+ const config = readConfig();
1050
+ return validateCaptureRequirements(config);
1051
+ } catch (e) {
1052
+ return {
1053
+ valid: false,
1054
+ errors: [e.message],
1055
+ warnings: [],
1056
+ };
1057
+ }
1058
+ }
1059
+
1060
+ // ===== VERSIONING CONFIGURATION =====
1061
+
1062
+ /**
1063
+ * Get versioning configuration with defaults
1064
+ * Supports pinned versions vs live head URLs
1065
+ * @returns {Object} Versioning config
1066
+ */
1067
+ function getVersioningConfig() {
1068
+ try {
1069
+ const config = readConfig();
1070
+ return {
1071
+ // Default URL type: "live" (always latest) or "pinned" (specific version)
1072
+ defaultUrlType: config.versioning?.defaultUrlType ?? "live",
1073
+ // Current pinned tag (if any)
1074
+ pinnedTag: config.versioning?.pinnedTag ?? null,
1075
+ // All available tags
1076
+ tags: config.versioning?.tags ?? [],
1077
+ };
1078
+ } catch (e) {
1079
+ return {
1080
+ defaultUrlType: "live",
1081
+ pinnedTag: null,
1082
+ tags: [],
1083
+ };
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Update versioning configuration
1089
+ * @param {Object} versioningConfig - New versioning config
1090
+ */
1091
+ function updateVersioningConfig(versioningConfig) {
1092
+ const config = readConfig();
1093
+ config.versioning = {
1094
+ ...config.versioning,
1095
+ ...versioningConfig,
1096
+ };
1097
+ writeConfig(config);
1098
+ return config.versioning;
1099
+ }
1100
+
1101
+ /**
1102
+ * Add a new tag to versioning history
1103
+ * @param {string} tag - Tag name (e.g., "v1.2", "release-2024-01")
1104
+ * @param {Object} metadata - Tag metadata
1105
+ */
1106
+ function addVersionTag(tag, metadata = {}) {
1107
+ const config = readConfig();
1108
+ config.versioning = config.versioning || { tags: [] };
1109
+ config.versioning.tags = config.versioning.tags || [];
1110
+
1111
+ // Ensure no duplicate tags
1112
+ const existingIndex = config.versioning.tags.findIndex((t) => t.name === tag);
1113
+ const tagData = {
1114
+ name: tag,
1115
+ createdAt: new Date().toISOString(),
1116
+ commitHash: metadata.commitHash || null,
1117
+ ...metadata,
1118
+ };
1119
+
1120
+ if (existingIndex >= 0) {
1121
+ config.versioning.tags[existingIndex] = tagData;
1122
+ } else {
1123
+ config.versioning.tags.push(tagData);
1124
+ }
1125
+
1126
+ writeConfig(config);
1127
+ return tagData;
1128
+ }
1129
+
1130
+ // ===== OUTPUT FORMAT CONFIGURATION =====
1131
+
1132
+ /**
1133
+ * Get the preferred output format configuration
1134
+ * Prioritizes GIF/video for animations, PNG for static captures
1135
+ * @returns {Object} Output format preferences
1136
+ */
1137
+ function getOutputFormatConfig() {
1138
+ try {
1139
+ const config = readConfig();
1140
+ return {
1141
+ // Primary format for multi-step scenarios (prefer GIF for animations)
1142
+ primaryFormat: config.output?.primaryFormat ?? "gif",
1143
+ // Fallback format for static single-step captures
1144
+ staticFormat: config.output?.staticFormat ?? "png",
1145
+ // Video format for full recordings
1146
+ videoFormat: config.output?.videoFormat ?? "mp4",
1147
+ // GIF settings
1148
+ gif: {
1149
+ loop: config.output?.gif?.loop ?? true, // Loop infinitely by default
1150
+ fps: config.output?.gif?.fps ?? 15, // Frames per second
1151
+ quality: config.output?.gif?.quality ?? "high", // high, medium, low
1152
+ maxDuration: config.output?.gif?.maxDuration ?? 10000, // Max 10 seconds
1153
+ },
1154
+ // Video settings
1155
+ video: {
1156
+ codec: config.output?.video?.codec ?? "h264",
1157
+ fps: config.output?.video?.fps ?? 30,
1158
+ quality: config.output?.video?.quality ?? "high",
1159
+ },
1160
+ };
1161
+ } catch (e) {
1162
+ return {
1163
+ primaryFormat: "gif",
1164
+ staticFormat: "png",
1165
+ videoFormat: "mp4",
1166
+ gif: { loop: true, fps: 15, quality: "high", maxDuration: 10000 },
1167
+ video: { codec: "h264", fps: 30, quality: "high" },
1168
+ };
1169
+ }
1170
+ }
1171
+
1172
+ /**
1173
+ * Update output format configuration
1174
+ * @param {Object} formatConfig - New format config
1175
+ */
1176
+ function updateOutputFormatConfig(formatConfig) {
1177
+ const config = readConfig();
1178
+ config.output = {
1179
+ ...config.output,
1180
+ ...formatConfig,
1181
+ };
1182
+ writeConfig(config);
1183
+ return config.output;
1184
+ }
1185
+
1186
+ module.exports = {
1187
+ readSettings,
1188
+ writeSettings,
1189
+ readConfig,
1190
+ writeConfig,
1191
+ configExists,
1192
+ initializeProject,
1193
+ // Capture configuration
1194
+ getCaptureConfig,
1195
+ DEFAULT_CAPTURE_CONFIG,
1196
+ // Privacy configuration
1197
+ getPrivacyConfig,
1198
+ DEFAULT_PRIVACY_CONFIG,
1199
+ // Style configuration
1200
+ getStyleConfig,
1201
+ DEFAULT_STYLE_CONFIG,
1202
+ // Diffing configuration
1203
+ getDiffingConfig,
1204
+ updateDiffingConfig,
1205
+ // Versioning configuration
1206
+ getVersioningConfig,
1207
+ updateVersioningConfig,
1208
+ addVersionTag,
1209
+ // Output format configuration
1210
+ getOutputFormatConfig,
1211
+ updateOutputFormatConfig,
1212
+ // Workspace management
1213
+ workspaceExists,
1214
+ readWorkspace,
1215
+ writeWorkspace,
1216
+ createWorkspace,
1217
+ addScenarioToWorkspace,
1218
+ removeScenarioFromWorkspace,
1219
+ updateWorkspaceVariants,
1220
+ getWorkspaceWithScenarios,
1221
+ // Auth helpers
1222
+ isAuthError,
1223
+ createAuthErrorResponse,
1224
+ // Output & viewport configuration
1225
+ getOutputConfig,
1226
+ updateOutputConfig,
1227
+ getViewportPresetsConfig,
1228
+ saveViewportPreset,
1229
+ deleteViewportPreset,
1230
+ // Mode & validation
1231
+ getModeInfo,
1232
+ validateConfig,
1233
+ // DocSync configuration
1234
+ readDocSyncConfig,
1235
+ // Paths
1236
+ SETTINGS_PATH,
1237
+ SETTINGS_DIR,
1238
+ CONFIG_PATH,
1239
+ WORKSPACE_PATH,
1240
+ };