@vizzly-testing/cli 0.39.1 → 0.39.2

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 (195) hide show
  1. package/README.md +8 -0
  2. package/bin/vizzly.js +1 -1
  3. package/dist/{cli.js → adapters/cli/cli.js} +41 -35
  4. package/dist/adapters/cli/setup.js +34 -0
  5. package/dist/{client → adapters/client}/index.js +6 -7
  6. package/dist/adapters/http/cloud-proxy.js +29 -0
  7. package/dist/{server → adapters/http}/http-server.js +12 -13
  8. package/dist/{server-manager/operations.js → adapters/http/server-lifecycle.js} +10 -13
  9. package/dist/{server-manager/index.js → adapters/http/server-manager.js} +23 -27
  10. package/dist/{plugin-api.js → adapters/plugins/plugin-api.js} +1 -1
  11. package/dist/{plugin-loader.js → adapters/plugins/plugin-loader.js} +1 -1
  12. package/dist/{services/index.js → adapters/plugins/services.js} +4 -9
  13. package/dist/adapters/sdk/index.d.ts +89 -0
  14. package/dist/{sdk → adapters/sdk}/index.js +22 -19
  15. package/dist/{commands/api.js → domains/api/api.commands.js} +3 -3
  16. package/dist/{auth/client.js → domains/auth/auth-client.js} +4 -3
  17. package/dist/domains/auth/auth-errors.js +50 -0
  18. package/dist/domains/auth/auth-headers.js +41 -0
  19. package/dist/domains/auth/auth.normalizers.js +50 -0
  20. package/dist/{server/routers/auth.js → domains/auth/auth.routes.js} +9 -9
  21. package/dist/domains/auth/auth.rules.js +19 -0
  22. package/dist/domains/auth/auth.serializers.js +32 -0
  23. package/dist/{auth/operations.js → domains/auth/auth.service.js} +115 -17
  24. package/dist/domains/auth/auth.validations.js +21 -0
  25. package/dist/{api/client.js → domains/auth/cloud-client.js} +11 -18
  26. package/dist/{commands/login.js → domains/auth/login.commands.js} +11 -8
  27. package/dist/{commands/logout.js → domains/auth/logout.commands.js} +6 -4
  28. package/dist/domains/auth/token-store.js +81 -0
  29. package/dist/{commands/whoami.js → domains/auth/whoami.commands.js} +6 -3
  30. package/dist/domains/baselines/baseline-download.js +192 -0
  31. package/dist/domains/baselines/baseline-write.js +22 -0
  32. package/dist/{server/routers/baseline.js → domains/baselines/baseline.routes.js} +3 -3
  33. package/dist/{tdd/services/baseline-manager.js → domains/baselines/baseline.service.js} +1 -39
  34. package/dist/{commands/baselines.js → domains/baselines/baselines.commands.js} +3 -3
  35. package/dist/domains/baselines/cloud-baselines.js +9 -0
  36. package/dist/domains/baselines/cloud-baselines.routes.js +42 -0
  37. package/dist/domains/builds/build.normalizers.js +35 -0
  38. package/dist/domains/builds/build.serializers.js +164 -0
  39. package/dist/domains/builds/build.service.js +119 -0
  40. package/dist/domains/builds/build.validations.js +39 -0
  41. package/dist/{commands/builds.js → domains/builds/builds.commands.js} +6 -74
  42. package/dist/domains/builds/cloud-builds.routes.js +31 -0
  43. package/dist/{commands/finalize.js → domains/builds/finalize.commands.js} +5 -17
  44. package/dist/{commands/status.js → domains/builds/status.commands.js} +9 -120
  45. package/dist/domains/comparisons/cloud-comparisons.js +43 -0
  46. package/dist/{tdd/services/comparison-service.js → domains/comparisons/comparison.service.js} +1 -1
  47. package/dist/{commands/comparisons.js → domains/comparisons/comparisons.commands.js} +7 -5
  48. package/dist/domains/config/config-defaults.js +27 -0
  49. package/dist/{utils → domains/config}/config-discovery.js +1 -9
  50. package/dist/{utils → domains/config}/config-loader.js +10 -9
  51. package/dist/{commands/config-cmd.js → domains/config/config.commands.js} +4 -4
  52. package/dist/{types → domains/config}/config.d.ts +3 -3
  53. package/dist/domains/config/config.normalizers.js +126 -0
  54. package/dist/{server/routers/config.js → domains/config/config.routes.js} +13 -13
  55. package/dist/domains/config/config.rules.js +47 -0
  56. package/dist/domains/config/config.serializers.js +202 -0
  57. package/dist/domains/config/config.service.js +625 -0
  58. package/dist/{utils/config-schema.js → domains/config/config.validations.js} +9 -9
  59. package/dist/{utils/environment-config.js → domains/config/environment.js} +1 -8
  60. package/dist/domains/context/compact-context.validations.js +60 -0
  61. package/dist/domains/context/context-presentation.js +368 -0
  62. package/dist/domains/context/context.commands.js +385 -0
  63. package/dist/domains/context/context.serializers.js +157 -0
  64. package/dist/domains/context/context.service.js +76 -0
  65. package/dist/domains/context/context.validations.js +100 -0
  66. package/dist/{context → domains/context}/local-workspace-provider.js +1 -1
  67. package/dist/{commands/doctor.js → domains/diagnostics/doctor.commands.js} +6 -5
  68. package/dist/{server/routers/health.js → domains/diagnostics/health.routes.js} +11 -20
  69. package/dist/{commands/preview.js → domains/previews/preview.commands.js} +9 -7
  70. package/dist/domains/previews/preview.service.js +37 -0
  71. package/dist/domains/projects/cloud-projects.routes.js +28 -0
  72. package/dist/{commands/orgs.js → domains/projects/orgs.commands.js} +6 -8
  73. package/dist/{utils → domains/projects}/project-binding.js +3 -3
  74. package/dist/domains/projects/project-errors.js +61 -0
  75. package/dist/{utils → domains/projects}/project-link-store.js +2 -2
  76. package/dist/domains/projects/project-requests.js +61 -0
  77. package/dist/{commands/project.js → domains/projects/project.commands.js} +9 -41
  78. package/dist/domains/projects/project.normalizers.js +69 -0
  79. package/dist/{project/operations.js → domains/projects/project.service.js} +89 -16
  80. package/dist/domains/projects/project.validations.js +31 -0
  81. package/dist/{commands/projects.js → domains/projects/projects.commands.js} +6 -8
  82. package/dist/{server/routers/projects.js → domains/projects/projects.routes.js} +5 -5
  83. package/dist/domains/reports/dashboard.routes.js +96 -0
  84. package/dist/{server/routers/events.js → domains/reports/events.routes.js} +14 -36
  85. package/dist/domains/reports/report-assets.routes.js +81 -0
  86. package/dist/domains/reports/report-state.js +51 -0
  87. package/dist/domains/reports/report-store.js +85 -0
  88. package/dist/domains/reports/report.serializers.js +99 -0
  89. package/dist/{services/static-report-generator.js → domains/reports/report.service.js} +11 -33
  90. package/dist/{commands/review.js → domains/review/review.commands.js} +4 -4
  91. package/dist/domains/runs/run-environment.js +69 -0
  92. package/dist/{services/test-runner.js → domains/runs/run-events.js} +11 -9
  93. package/dist/{commands/run.js → domains/runs/run.commands.js} +16 -13
  94. package/dist/domains/runs/run.normalizers.js +8 -0
  95. package/dist/domains/runs/run.rules.js +56 -0
  96. package/dist/domains/runs/run.serializers.js +101 -0
  97. package/dist/{test-runner/operations.js → domains/runs/run.service.js} +35 -7
  98. package/dist/domains/runs/run.validations.js +17 -0
  99. package/dist/{utils/session.js → domains/runs/session-store.js} +3 -2
  100. package/dist/{server/handlers/api-handler.js → domains/screenshots/cloud-handler.js} +4 -4
  101. package/dist/domains/screenshots/cloud-screenshots.js +109 -0
  102. package/dist/{utils/file-helpers.js → domains/screenshots/image-files.js} +1 -1
  103. package/dist/domains/screenshots/screenshot-hash.js +53 -0
  104. package/dist/domains/screenshots/screenshot-server-options.js +28 -0
  105. package/dist/{screenshot-server/operations.js → domains/screenshots/screenshot-server.js} +14 -1
  106. package/dist/domains/screenshots/screenshot.normalizers.js +40 -0
  107. package/dist/{server/routers/screenshot.js → domains/screenshots/screenshot.routes.js} +4 -4
  108. package/dist/domains/screenshots/screenshot.serializers.js +146 -0
  109. package/dist/{utils/security.js → domains/screenshots/screenshot.validations.js} +20 -70
  110. package/dist/{init/agents.js → domains/setup/agent-setup.js} +4 -5
  111. package/dist/{commands/init.js → domains/setup/init.commands.js} +10 -41
  112. package/dist/{server/handlers/tdd-handler.js → domains/tdd/screenshot-handler.js} +46 -281
  113. package/dist/{commands/tdd-daemon.js → domains/tdd/tdd-daemon.commands.js} +6 -6
  114. package/dist/{commands/tdd-screenshots.js → domains/tdd/tdd-screenshots.commands.js} +5 -5
  115. package/dist/{commands/tdd.js → domains/tdd/tdd.commands.js} +16 -15
  116. package/dist/{tdd/tdd-service.js → domains/tdd/tdd.service.js} +81 -405
  117. package/dist/{utils → domains/updates}/cli-update.js +4 -6
  118. package/dist/{utils → domains/updates}/update-notifications.js +7 -7
  119. package/dist/domains/uploads/upload-defaults.js +4 -0
  120. package/dist/domains/uploads/upload-files.js +32 -0
  121. package/dist/domains/uploads/upload-settings.js +21 -0
  122. package/dist/domains/uploads/upload-timing.js +18 -0
  123. package/dist/{commands/upload.js → domains/uploads/upload.commands.js} +11 -9
  124. package/dist/domains/uploads/upload.normalizers.js +60 -0
  125. package/dist/domains/uploads/upload.serializers.js +179 -0
  126. package/dist/{uploader/operations.js → domains/uploads/upload.service.js} +85 -28
  127. package/dist/domains/uploads/upload.validations.js +77 -0
  128. package/dist/index.js +12 -12
  129. package/dist/reporter/reporter-bundle.iife.js +86 -86
  130. package/dist/reporter-ssr/ssr-entry.js +2 -2
  131. package/dist/shared/cloud-protocol.js +171 -0
  132. package/dist/{utils → shared}/file-changes.js +1 -1
  133. package/dist/shared/files.js +9 -0
  134. package/dist/{utils → shared}/global-config.js +7 -78
  135. package/dist/shared/log-level.js +7 -0
  136. package/dist/{utils → shared}/output.js +1 -1
  137. package/dist/{utils → shared}/package-info.js +5 -4
  138. package/dist/shared/paths.js +57 -0
  139. package/package.json +23 -22
  140. package/dist/api/core.js +0 -382
  141. package/dist/api/endpoints.js +0 -421
  142. package/dist/api/index.js +0 -19
  143. package/dist/auth/core.js +0 -237
  144. package/dist/auth/index.js +0 -31
  145. package/dist/commands/context.js +0 -1066
  146. package/dist/config/core.js +0 -439
  147. package/dist/config/operations.js +0 -327
  148. package/dist/project/core.js +0 -211
  149. package/dist/screenshot-server/core.js +0 -161
  150. package/dist/screenshot-server/index.js +0 -11
  151. package/dist/server/routers/assets.js +0 -95
  152. package/dist/server/routers/cloud-proxy.js +0 -146
  153. package/dist/server/routers/dashboard.js +0 -172
  154. package/dist/services/auth-service.js +0 -117
  155. package/dist/services/config-service.js +0 -313
  156. package/dist/services/project-service.js +0 -88
  157. package/dist/test-runner/core.js +0 -293
  158. package/dist/test-runner/index.js +0 -13
  159. package/dist/types/sdk.d.ts +0 -186
  160. package/dist/uploader/core.js +0 -432
  161. package/dist/uploader/index.js +0 -88
  162. /package/dist/{types/client.d.ts → adapters/client/index.d.ts} +0 -0
  163. /package/dist/{server → adapters/http}/middleware/cors.js +0 -0
  164. /package/dist/{server → adapters/http}/middleware/json-parser.js +0 -0
  165. /package/dist/{server → adapters/http}/middleware/response.js +0 -0
  166. /package/dist/{server-manager/core.js → adapters/http/server-options.js} +0 -0
  167. /package/dist/{utils/login-poll-wait.js → domains/auth/login-wait.js} +0 -0
  168. /package/dist/{tdd/metadata → domains/baselines}/baseline-metadata.js +0 -0
  169. /package/dist/{services/build-manager.js → domains/builds/build-info.js} +0 -0
  170. /package/dist/{tdd/services/result-service.js → domains/comparisons/comparison-results.js} +0 -0
  171. /package/dist/{init/core.js → domains/config/config-plan.js} +0 -0
  172. /package/dist/{utils/config-helpers.js → domains/config/config.js} +0 -0
  173. /package/dist/{utils/visual-context-normalizers.js → domains/context/context.normalizers.js} +0 -0
  174. /package/dist/{context → domains/context}/provider-resolver.js +0 -0
  175. /package/dist/{utils/context.js → domains/diagnostics/environment-context.js} +0 -0
  176. /package/dist/{utils/report-data.js → domains/reports/report.normalizers.js} +0 -0
  177. /package/dist/{utils/image-input-detector.js → domains/screenshots/image-input.js} +0 -0
  178. /package/dist/{tdd/core/signature.js → domains/screenshots/screenshot-identity.js} +0 -0
  179. /package/dist/{utils → domains/screenshots}/screenshot-options.js +0 -0
  180. /package/dist/{init/prompts.js → domains/setup/setup-prompts.js} +0 -0
  181. /package/dist/{tdd → domains/tdd}/runtime-context.js +0 -0
  182. /package/dist/{tdd → domains/tdd}/server-registry.js +0 -0
  183. /package/dist/{commands/tdd-daemon-process.js → domains/tdd/tdd-daemon-process.commands.js} +0 -0
  184. /package/dist/{utils → domains/updates}/update-check.js +0 -0
  185. /package/dist/{types/index.d.ts → index.d.ts} +0 -0
  186. /package/dist/{utils → shared}/api-url.js +0 -0
  187. /package/dist/{utils → shared}/async-utils.js +0 -0
  188. /package/dist/{utils → shared}/browser.js +0 -0
  189. /package/dist/{utils → shared}/ci-env.js +0 -0
  190. /package/dist/{utils → shared}/colors.js +0 -0
  191. /package/dist/{utils → shared}/fetch-utils.js +0 -0
  192. /package/dist/{utils → shared}/git.js +0 -0
  193. /package/dist/{utils → shared}/patterns.js +0 -0
  194. /package/dist/{utils → shared}/secret-store.js +0 -0
  195. /package/dist/{errors → shared}/vizzly-error.js +0 -0
@@ -0,0 +1,625 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { cosmiconfigSync } from 'cosmiconfig';
5
+ import { loadGlobalConfig, saveGlobalConfig } from '../../shared/global-config.js';
6
+ import * as output from '../../shared/output.js';
7
+ import { VizzlyError } from '../../shared/vizzly-error.js';
8
+ import { deepMerge, extractCosmiconfigResult, extractEnvOverrides } from './config.normalizers.js';
9
+ import { validateReadScope, validateWriteScope } from './config.rules.js';
10
+ import { buildGlobalConfigResult, buildMergedConfigResult, buildProjectConfigResult, getConfigFormat, serializeConfig } from './config.serializers.js';
11
+ import { CONFIG_DEFAULTS } from './config-defaults.js';
12
+
13
+ /**
14
+ * Config Operations - Configuration operations with dependency injection
15
+ *
16
+ * Each operation takes its dependencies as parameters:
17
+ * - explorer: cosmiconfig explorer for project config
18
+ * - globalConfigStore: for reading/writing global config
19
+ * - fileWriter: for writing project config files
20
+ *
21
+ * This makes them trivially testable without mocking modules.
22
+ */
23
+
24
+ /**
25
+ * Config Service
26
+ * Manages configuration for the TDD dashboard settings page
27
+ *
28
+ * Provides read/write access to:
29
+ * - Merged config (read-only combination of all sources)
30
+ * - Project config (vizzly.config.js in working directory)
31
+ * - Global config (~/.vizzly/config.json)
32
+ */
33
+
34
+ /**
35
+ * Create a config service instance
36
+ * @param {Object} options
37
+ * @param {string} options.workingDir - Working directory for project config
38
+ * @returns {Object} Config service with getConfig, updateConfig, validateConfig methods
39
+ */
40
+ export function createConfigService({
41
+ workingDir
42
+ }) {
43
+ let projectConfigPath = null;
44
+ let projectConfigFormat = 'js'; // 'js' or 'json'
45
+
46
+ // Find project config file
47
+ let explorer = cosmiconfigSync('vizzly');
48
+ let searchResult = explorer.search(workingDir);
49
+ if (searchResult?.filepath) {
50
+ projectConfigPath = searchResult.filepath;
51
+ projectConfigFormat = searchResult.filepath.endsWith('.json') ? 'json' : 'js';
52
+ }
53
+
54
+ /**
55
+ * Get configuration by type
56
+ * @param {'merged'|'project'|'global'} type
57
+ * @returns {Promise<Object>}
58
+ */
59
+ async function getConfig(type) {
60
+ if (type === 'merged') {
61
+ return getMergedConfig();
62
+ } else if (type === 'project') {
63
+ return getProjectConfig();
64
+ } else if (type === 'global') {
65
+ return getGlobalConfigData();
66
+ }
67
+ throw new Error(`Unknown config type: ${type}`);
68
+ }
69
+
70
+ /**
71
+ * Get merged configuration with source tracking
72
+ */
73
+ async function getMergedConfig() {
74
+ let config = cloneConfigValue(CONFIG_DEFAULTS);
75
+ let sources = {};
76
+
77
+ // Layer 1: Global config
78
+ let globalConfig = await loadGlobalConfig();
79
+ if (globalConfig.settings) {
80
+ mergeWithTracking(config, globalConfig.settings, sources, 'global');
81
+ }
82
+
83
+ // Layer 2: Project config
84
+ if (projectConfigPath && existsSync(projectConfigPath)) {
85
+ try {
86
+ let result = explorer.load(projectConfigPath);
87
+ let projectConfig = result?.config?.default || result?.config || {};
88
+ mergeWithTracking(config, projectConfig, sources, 'project');
89
+ } catch (error) {
90
+ output.debug('config-service', `Error loading project config: ${error.message}`);
91
+ }
92
+ }
93
+
94
+ // Layer 3: Environment variables
95
+ if (process.env.VIZZLY_THRESHOLD) {
96
+ config.comparison.threshold = Number(process.env.VIZZLY_THRESHOLD);
97
+ sources.comparison = 'env';
98
+ }
99
+ if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
100
+ config.comparison.minClusterSize = Number(process.env.VIZZLY_MIN_CLUSTER_SIZE);
101
+ sources.comparison = 'env';
102
+ }
103
+ if (process.env.VIZZLY_PORT) {
104
+ config.server.port = Number(process.env.VIZZLY_PORT);
105
+ sources.server = 'env';
106
+ }
107
+ return {
108
+ config,
109
+ sources
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Get project-level configuration only
115
+ */
116
+ async function getProjectConfig() {
117
+ if (!projectConfigPath || !existsSync(projectConfigPath)) {
118
+ return {
119
+ config: {},
120
+ path: null
121
+ };
122
+ }
123
+ try {
124
+ let result = explorer.load(projectConfigPath);
125
+ let config = result?.config?.default || result?.config || {};
126
+ return {
127
+ config,
128
+ path: projectConfigPath
129
+ };
130
+ } catch (error) {
131
+ output.debug('config-service', `Error loading project config: ${error.message}`);
132
+ return {
133
+ config: {},
134
+ path: projectConfigPath,
135
+ error: error.message
136
+ };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get global configuration only
142
+ */
143
+ async function getGlobalConfigData() {
144
+ let globalConfig = await loadGlobalConfig();
145
+ return {
146
+ config: globalConfig.settings || {},
147
+ path: join(process.env.VIZZLY_HOME || join(process.env.HOME || '', '.vizzly'), 'config.json')
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Update configuration by type
153
+ * @param {'project'|'global'} type
154
+ * @param {Object} updates - Config updates to apply
155
+ * @returns {Promise<Object>}
156
+ */
157
+ async function updateConfig(type, updates) {
158
+ if (type === 'project') {
159
+ return updateProjectConfig(updates);
160
+ } else if (type === 'global') {
161
+ return updateGlobalConfig(updates);
162
+ }
163
+ throw new Error(`Cannot update config type: ${type}`);
164
+ }
165
+
166
+ /**
167
+ * Update project configuration (vizzly.config.js)
168
+ */
169
+ async function updateProjectConfig(updates) {
170
+ // If no project config exists, create one
171
+ if (!projectConfigPath) {
172
+ projectConfigPath = join(workingDir, 'vizzly.config.js');
173
+ projectConfigFormat = 'js';
174
+ }
175
+
176
+ // Read existing config
177
+ let existingConfig = {};
178
+ if (existsSync(projectConfigPath)) {
179
+ try {
180
+ let result = explorer.load(projectConfigPath);
181
+ existingConfig = result?.config?.default || result?.config || {};
182
+ } catch {
183
+ // Start fresh if corrupted
184
+ }
185
+ }
186
+
187
+ // Merge updates
188
+ let newConfig = mergeDeep(existingConfig, updates);
189
+
190
+ // Write based on format
191
+ if (projectConfigFormat === 'json') {
192
+ await writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2));
193
+ } else {
194
+ // Write as ES module
195
+ let content = `import { defineConfig } from '@vizzly-testing/cli/config';
196
+
197
+ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
198
+ `;
199
+ await writeFile(projectConfigPath, content);
200
+ }
201
+
202
+ // Clear cosmiconfig cache so next read gets fresh data
203
+ explorer.clearCaches();
204
+ return {
205
+ success: true,
206
+ path: projectConfigPath
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Update global configuration (~/.vizzly/config.json)
212
+ */
213
+ async function updateGlobalConfig(updates) {
214
+ let globalConfig = await loadGlobalConfig();
215
+ if (!globalConfig.settings) {
216
+ globalConfig.settings = {};
217
+ }
218
+ globalConfig.settings = mergeDeep(globalConfig.settings, updates);
219
+ await saveGlobalConfig(globalConfig);
220
+ return {
221
+ success: true
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Validate configuration
227
+ * @param {Object} config - Config to validate
228
+ * @returns {Promise<Object>}
229
+ */
230
+ async function validateConfig(config) {
231
+ let errors = [];
232
+ let warnings = [];
233
+
234
+ // Validate threshold
235
+ if (config.comparison?.threshold !== undefined) {
236
+ let threshold = config.comparison.threshold;
237
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold) || threshold < 0) {
238
+ errors.push('comparison.threshold must be a non-negative number');
239
+ } else if (threshold > 100) {
240
+ warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
241
+ }
242
+ }
243
+
244
+ // Validate minClusterSize
245
+ if (config.comparison?.minClusterSize !== undefined) {
246
+ let minClusterSize = config.comparison.minClusterSize;
247
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
248
+ errors.push('comparison.minClusterSize must be a positive integer (1 or greater)');
249
+ } else if (minClusterSize > 100) {
250
+ warnings.push('comparison.minClusterSize above 100 may filter out most differences');
251
+ }
252
+ }
253
+
254
+ // Validate port
255
+ if (config.server?.port !== undefined) {
256
+ let port = config.server.port;
257
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
258
+ errors.push('server.port must be an integer between 1 and 65535');
259
+ } else if (port < 1024) {
260
+ warnings.push('server.port below 1024 may require elevated privileges');
261
+ }
262
+ }
263
+
264
+ // Validate timeout
265
+ if (config.server?.timeout !== undefined) {
266
+ let timeout = config.server.timeout;
267
+ if (!Number.isInteger(timeout) || timeout < 0) {
268
+ errors.push('server.timeout must be a non-negative integer');
269
+ }
270
+ }
271
+ return {
272
+ valid: errors.length === 0,
273
+ errors,
274
+ warnings
275
+ };
276
+ }
277
+ return {
278
+ getConfig,
279
+ updateConfig,
280
+ validateConfig
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Deep merge two objects
286
+ */
287
+ function mergeDeep(target, source) {
288
+ let result = cloneConfigValue(target);
289
+ for (let key in source) {
290
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
291
+ result[key] = mergeDeep(result[key] || {}, source[key]);
292
+ } else if (Array.isArray(source[key])) {
293
+ result[key] = [...source[key]];
294
+ } else {
295
+ result[key] = source[key];
296
+ }
297
+ }
298
+ return result;
299
+ }
300
+ function cloneConfigValue(value) {
301
+ if (Array.isArray(value)) {
302
+ return value.map(cloneConfigValue);
303
+ }
304
+ if (value && typeof value === 'object') {
305
+ let clone = {};
306
+ for (let key in value) {
307
+ clone[key] = cloneConfigValue(value[key]);
308
+ }
309
+ return clone;
310
+ }
311
+ return value;
312
+ }
313
+
314
+ /**
315
+ * Merge config with source tracking
316
+ */
317
+ function mergeWithTracking(target, source, sources, sourceName, sectionName) {
318
+ for (let key in source) {
319
+ let sourceSection = sectionName || key;
320
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
321
+ if (!target[key]) target[key] = {};
322
+ mergeWithTracking(target[key], source[key], sources, sourceName, sourceSection);
323
+ } else {
324
+ target[key] = source[key];
325
+ sources[sourceSection] = sourceName;
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get project-level config from vizzly.config.js or similar
332
+ * @param {Object} explorer - Cosmiconfig explorer with search method
333
+ * @param {string} projectRoot - Project root directory
334
+ * @returns {Promise<{ config: Object, filepath: string|null, isEmpty: boolean }>}
335
+ */
336
+ export async function getProjectConfig(explorer, projectRoot) {
337
+ let result = explorer.search(projectRoot);
338
+ let {
339
+ config,
340
+ filepath
341
+ } = extractCosmiconfigResult(result);
342
+ return buildProjectConfigResult(config, filepath);
343
+ }
344
+
345
+ /**
346
+ * Get global config from ~/.vizzly/config.json
347
+ * @param {Object} globalConfigStore - Store with load and getPath methods
348
+ * @returns {Promise<{ config: Object, filepath: string, isEmpty: boolean }>}
349
+ */
350
+ export async function getGlobalConfig(globalConfigStore) {
351
+ let config = await globalConfigStore.load();
352
+ let filepath = globalConfigStore.getPath();
353
+ return buildGlobalConfigResult(config, filepath);
354
+ }
355
+
356
+ /**
357
+ * Get merged config from all sources with source tracking
358
+ * @param {Object} options - Options
359
+ * @param {Object} options.explorer - Cosmiconfig explorer
360
+ * @param {Object} options.globalConfigStore - Global config store
361
+ * @param {string} options.projectRoot - Project root directory
362
+ * @param {Object} [options.env] - Environment variables (defaults to process.env)
363
+ * @returns {Promise<{ config: Object, sources: Object, projectFilepath: string|null, globalFilepath: string }>}
364
+ */
365
+ export async function getMergedConfig({
366
+ explorer,
367
+ globalConfigStore,
368
+ projectRoot,
369
+ env = process.env
370
+ }) {
371
+ let projectConfigData = await getProjectConfig(explorer, projectRoot);
372
+ let globalConfigData = await getGlobalConfig(globalConfigStore);
373
+ let envOverrides = extractEnvOverrides(env);
374
+ return buildMergedConfigResult({
375
+ projectConfig: projectConfigData.config,
376
+ globalConfig: globalConfigData.config,
377
+ envOverrides,
378
+ projectFilepath: projectConfigData.filepath,
379
+ globalFilepath: globalConfigData.filepath
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Get configuration based on scope
385
+ * @param {Object} options - Options
386
+ * @param {string} options.scope - 'project', 'global', or 'merged'
387
+ * @param {Object} options.explorer - Cosmiconfig explorer
388
+ * @param {Object} options.globalConfigStore - Global config store
389
+ * @param {string} options.projectRoot - Project root directory
390
+ * @param {Object} [options.env] - Environment variables
391
+ * @returns {Promise<Object>} Config result based on scope
392
+ */
393
+ export async function getConfig({
394
+ scope = 'merged',
395
+ explorer,
396
+ globalConfigStore,
397
+ projectRoot,
398
+ env
399
+ }) {
400
+ let validation = validateReadScope(scope);
401
+ if (!validation.valid) {
402
+ throw validation.error;
403
+ }
404
+ if (scope === 'project') {
405
+ return getProjectConfig(explorer, projectRoot);
406
+ }
407
+ if (scope === 'global') {
408
+ return getGlobalConfig(globalConfigStore);
409
+ }
410
+ return getMergedConfig({
411
+ explorer,
412
+ globalConfigStore,
413
+ projectRoot,
414
+ env
415
+ });
416
+ }
417
+
418
+ /**
419
+ * Update project-level config
420
+ * @param {Object} options - Options
421
+ * @param {Object} options.updates - Config updates to apply
422
+ * @param {Object} options.explorer - Cosmiconfig explorer
423
+ * @param {string} options.projectRoot - Project root directory
424
+ * @param {Function} options.writeFile - Async file writer (path, content) => Promise
425
+ * @param {Function} options.readFile - Async file reader (path) => Promise<string>
426
+ * @param {Function} options.validate - Config validator
427
+ * @returns {Promise<{ config: Object, filepath: string }>}
428
+ */
429
+ export async function updateProjectConfig({
430
+ updates,
431
+ explorer,
432
+ projectRoot,
433
+ writeFile,
434
+ readFile,
435
+ validate
436
+ }) {
437
+ let result = explorer.search(projectRoot);
438
+ let {
439
+ config: currentConfig,
440
+ filepath: configPath
441
+ } = extractCosmiconfigResult(result);
442
+
443
+ // Determine config file path - create new if none exists
444
+ if (!configPath) {
445
+ configPath = join(projectRoot, 'vizzly.config.js');
446
+ currentConfig = {};
447
+ }
448
+
449
+ // Merge updates with current config
450
+ let newConfig = deepMerge(currentConfig, updates);
451
+
452
+ // Validate before writing
453
+ try {
454
+ validate(newConfig);
455
+ } catch (error) {
456
+ throw new VizzlyError(`Invalid configuration: ${error.message}`, 'CONFIG_VALIDATION_ERROR', {
457
+ errors: error.errors
458
+ });
459
+ }
460
+
461
+ // Write config file based on format
462
+ await writeProjectConfigFile({
463
+ filepath: configPath,
464
+ config: newConfig,
465
+ writeFile,
466
+ readFile
467
+ });
468
+
469
+ // Clear cosmiconfig cache
470
+ explorer.clearCaches();
471
+ return {
472
+ config: newConfig,
473
+ filepath: configPath
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Write project config to file
479
+ * @param {Object} options - Options
480
+ * @param {string} options.filepath - Path to write to
481
+ * @param {Object} options.config - Config object
482
+ * @param {Function} options.writeFile - Async file writer
483
+ * @param {Function} options.readFile - Async file reader (for package.json)
484
+ * @returns {Promise<void>}
485
+ */
486
+ export async function writeProjectConfigFile({
487
+ filepath,
488
+ config,
489
+ writeFile,
490
+ readFile
491
+ }) {
492
+ let format = getConfigFormat(filepath);
493
+ if (format === 'package') {
494
+ // For package.json, merge into existing
495
+ let pkgContent = await readFile(filepath);
496
+ let pkg;
497
+ try {
498
+ pkg = JSON.parse(pkgContent);
499
+ } catch (error) {
500
+ throw new VizzlyError(`Failed to parse package.json: ${error.message}`, 'INVALID_PACKAGE_JSON');
501
+ }
502
+ pkg.vizzly = config;
503
+ await writeFile(filepath, JSON.stringify(pkg, null, 2));
504
+ return;
505
+ }
506
+ let serialized = serializeConfig(config, filepath);
507
+ if (serialized.error) {
508
+ throw serialized.error;
509
+ }
510
+ await writeFile(filepath, serialized.content);
511
+ }
512
+
513
+ /**
514
+ * Update global config
515
+ * @param {Object} options - Options
516
+ * @param {Object} options.updates - Config updates to apply
517
+ * @param {Object} options.globalConfigStore - Global config store with load and save methods
518
+ * @returns {Promise<{ config: Object, filepath: string }>}
519
+ */
520
+ export async function updateGlobalConfig({
521
+ updates,
522
+ globalConfigStore
523
+ }) {
524
+ let currentConfig = await globalConfigStore.load();
525
+ let newConfig = deepMerge(currentConfig, updates);
526
+ await globalConfigStore.save(newConfig);
527
+ return {
528
+ config: newConfig,
529
+ filepath: globalConfigStore.getPath()
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Update configuration based on scope
535
+ * @param {Object} options - Options
536
+ * @param {string} options.scope - 'project' or 'global'
537
+ * @param {Object} options.updates - Config updates to apply
538
+ * @param {Object} options.explorer - Cosmiconfig explorer
539
+ * @param {Object} options.globalConfigStore - Global config store
540
+ * @param {string} options.projectRoot - Project root directory
541
+ * @param {Function} options.writeFile - Async file writer
542
+ * @param {Function} options.readFile - Async file reader
543
+ * @param {Function} options.validate - Config validator
544
+ * @returns {Promise<{ config: Object, filepath: string }>}
545
+ */
546
+ export async function updateConfig({
547
+ scope,
548
+ updates,
549
+ explorer,
550
+ globalConfigStore,
551
+ projectRoot,
552
+ writeFile,
553
+ readFile,
554
+ validate
555
+ }) {
556
+ let validation = validateWriteScope(scope);
557
+ if (!validation.valid) {
558
+ throw validation.error;
559
+ }
560
+ if (scope === 'project') {
561
+ return updateProjectConfig({
562
+ updates,
563
+ explorer,
564
+ projectRoot,
565
+ writeFile,
566
+ readFile,
567
+ validate
568
+ });
569
+ }
570
+ return updateGlobalConfig({
571
+ updates,
572
+ globalConfigStore
573
+ });
574
+ }
575
+
576
+ /**
577
+ * Validate configuration object
578
+ * @param {Object} config - Config to validate
579
+ * @param {Function} validateFn - Validation function
580
+ * @returns {{ valid: boolean, config: Object|null, errors: Array }}
581
+ */
582
+ export function validateConfig(config, validateFn) {
583
+ try {
584
+ let validated = validateFn(config);
585
+ return {
586
+ valid: true,
587
+ config: validated,
588
+ errors: []
589
+ };
590
+ } catch (error) {
591
+ return {
592
+ valid: false,
593
+ config: null,
594
+ errors: error.errors || [{
595
+ message: error.message
596
+ }]
597
+ };
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Get the source of a specific config key
603
+ * @param {Object} options - Options
604
+ * @param {string} options.key - Config key to look up
605
+ * @param {Object} options.explorer - Cosmiconfig explorer
606
+ * @param {Object} options.globalConfigStore - Global config store
607
+ * @param {string} options.projectRoot - Project root directory
608
+ * @param {Object} [options.env] - Environment variables
609
+ * @returns {Promise<string>} Source ('default', 'global', 'project', 'env', 'unknown')
610
+ */
611
+ export async function getConfigSource({
612
+ key,
613
+ explorer,
614
+ globalConfigStore,
615
+ projectRoot,
616
+ env
617
+ }) {
618
+ let merged = await getMergedConfig({
619
+ explorer,
620
+ globalConfigStore,
621
+ projectRoot,
622
+ env
623
+ });
624
+ return merged.sources[key] || 'unknown';
625
+ }
@@ -1,15 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { CONFIG_DEFAULTS } from './config-defaults.js';
3
+
1
4
  /**
2
5
  * Configuration schema validation for Vizzly CLI
3
6
  * Uses Zod for runtime validation
4
7
  */
5
8
 
6
- import { z } from 'zod';
7
- import { CONFIG_DEFAULTS } from '../config/core.js';
8
-
9
9
  /**
10
10
  * Server configuration schema
11
11
  */
12
- const serverSchema = z.object({
12
+ let serverSchema = z.object({
13
13
  port: z.number().int().positive().default(CONFIG_DEFAULTS.server.port),
14
14
  timeout: z.number().int().positive().default(CONFIG_DEFAULTS.server.timeout)
15
15
  });
@@ -17,7 +17,7 @@ const serverSchema = z.object({
17
17
  /**
18
18
  * Build configuration schema
19
19
  */
20
- const buildSchema = z.object({
20
+ let buildSchema = z.object({
21
21
  name: z.string().default(CONFIG_DEFAULTS.build.name),
22
22
  environment: z.string().default(CONFIG_DEFAULTS.build.environment),
23
23
  branch: z.string().optional(),
@@ -28,7 +28,7 @@ const buildSchema = z.object({
28
28
  /**
29
29
  * Upload configuration schema
30
30
  */
31
- const uploadSchema = z.object({
31
+ let uploadSchema = z.object({
32
32
  screenshotsDir: z.union([z.string(), z.array(z.string())]).default(CONFIG_DEFAULTS.upload.screenshotsDir),
33
33
  batchSize: z.number().int().positive().default(CONFIG_DEFAULTS.upload.batchSize),
34
34
  timeout: z.number().int().positive().default(CONFIG_DEFAULTS.upload.timeout)
@@ -39,7 +39,7 @@ const uploadSchema = z.object({
39
39
  * threshold: CIEDE2000 Delta E units (0.0 = exact, 1.0 = JND, 2.0 = recommended, 3.0+ = permissive)
40
40
  * minClusterSize: pixels (1 = exact)
41
41
  */
42
- const comparisonSchema = z.object({
42
+ let comparisonSchema = z.object({
43
43
  threshold: z.number().min(0).default(CONFIG_DEFAULTS.comparison.threshold),
44
44
  minClusterSize: z.int().min(1).default(CONFIG_DEFAULTS.comparison.minClusterSize)
45
45
  });
@@ -47,7 +47,7 @@ const comparisonSchema = z.object({
47
47
  /**
48
48
  * TDD configuration schema
49
49
  */
50
- const tddSchema = z.object({
50
+ let tddSchema = z.object({
51
51
  openReport: z.boolean().default(CONFIG_DEFAULTS.tdd.openReport)
52
52
  });
53
53
  let projectSchema = z.object({
@@ -59,7 +59,7 @@ let projectSchema = z.object({
59
59
  * Core Vizzly configuration schema
60
60
  * Allows plugin-specific keys with passthrough for extensibility
61
61
  */
62
- export const vizzlyConfigSchema = z.object({
62
+ export let vizzlyConfigSchema = z.object({
63
63
  // Core Vizzly config
64
64
  apiKey: z.string().optional(),
65
65
  apiUrl: z.string().url().default(CONFIG_DEFAULTS.apiUrl),
@@ -1,3 +1,4 @@
1
+ import { getLogLevel } from '../../shared/log-level.js';
1
2
  /**
2
3
  * Environment Configuration Utility
3
4
  * Centralized access to environment variables with proper defaults
@@ -29,14 +30,6 @@ export function getApiUrl() {
29
30
  return process.env.VIZZLY_API_URL || 'https://app.vizzly.dev';
30
31
  }
31
32
 
32
- /**
33
- * Get log level from environment
34
- * @returns {string} Log level with default
35
- */
36
- export function getLogLevel() {
37
- return process.env.VIZZLY_LOG_LEVEL || 'info';
38
- }
39
-
40
33
  /**
41
34
  * Get user agent from environment
42
35
  * @returns {string|undefined} User agent string