@vizzly-testing/cli 0.30.0 → 0.31.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 (77) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/index.js +1 -1
  47. package/dist/tdd/metadata/region-metadata.js +0 -37
  48. package/dist/tdd/runtime-context.js +78 -0
  49. package/dist/tdd/server-registry.js +38 -17
  50. package/dist/tdd/services/baseline-manager.js +23 -0
  51. package/dist/tdd/services/hotspot-service.js +0 -13
  52. package/dist/tdd/tdd-service.js +82 -113
  53. package/dist/types/client.d.ts +36 -1
  54. package/dist/types/index.d.ts +114 -22
  55. package/dist/types/sdk.d.ts +40 -0
  56. package/dist/uploader/core.js +5 -4
  57. package/dist/uploader/index.js +79 -2
  58. package/dist/uploader/operations.js +40 -5
  59. package/dist/utils/api-url.js +14 -0
  60. package/dist/utils/async-utils.js +26 -0
  61. package/dist/utils/ci-env.js +19 -12
  62. package/dist/utils/config-loader.js +39 -65
  63. package/dist/utils/config-schema.js +19 -55
  64. package/dist/utils/fetch-utils.js +12 -5
  65. package/dist/utils/git.js +35 -72
  66. package/dist/utils/global-config.js +20 -1
  67. package/dist/utils/output.js +19 -7
  68. package/dist/utils/patterns.js +14 -0
  69. package/dist/utils/project-link-store.js +105 -0
  70. package/dist/utils/screenshot-options.js +28 -0
  71. package/dist/utils/secret-store.js +65 -0
  72. package/package.json +11 -8
  73. package/dist/services/screenshot-server.js +0 -65
  74. package/dist/services/server-manager.js +0 -81
  75. package/dist/services/uploader.js +0 -100
  76. package/dist/tdd/services/region-service.js +0 -75
  77. package/dist/utils/build-history.js +0 -103
@@ -1,23 +1,12 @@
1
1
  import { URL } from 'node:url';
2
- import { createApiClient, getBuilds } from '../api/index.js';
3
- import { ConfigError } from '../errors/vizzly-error.js';
4
- import { loadConfig } from '../utils/config-loader.js';
5
- import { getContext } from '../utils/context.js';
6
- import { getApiToken } from '../utils/environment-config.js';
7
- import * as output from '../utils/output.js';
8
-
9
- /**
10
- * Doctor command implementation - Run diagnostics to check environment
11
- * @param {Object} options - Command options
12
- * @param {Object} globalOptions - Global CLI options
13
- */
14
- export async function doctorCommand(options = {}, globalOptions = {}) {
15
- output.configure({
16
- json: globalOptions.json,
17
- verbose: globalOptions.verbose,
18
- color: !globalOptions.noColor
19
- });
20
- let diagnostics = {
2
+ import { createApiClient as defaultCreateApiClient, getBuilds as defaultGetBuilds } from '../api/index.js';
3
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
4
+ import { getContext as defaultGetContext } from '../utils/context.js';
5
+ import { getApiToken as defaultGetApiToken } from '../utils/environment-config.js';
6
+ import * as defaultOutput from '../utils/output.js';
7
+ export let MIN_NODE_MAJOR = 22;
8
+ export function createDoctorDiagnostics() {
9
+ return {
21
10
  environment: {
22
11
  nodeVersion: null,
23
12
  nodeVersionValid: null
@@ -35,79 +24,126 @@ export async function doctorCommand(options = {}, globalOptions = {}) {
35
24
  error: null
36
25
  }
37
26
  };
27
+ }
28
+ export function getNodeVersionCheck(nodeVersion = process.version, minMajor = MIN_NODE_MAJOR) {
29
+ let nodeMajor = parseNodeMajorVersion(nodeVersion);
30
+ let ok = nodeMajor !== null && nodeMajor >= minMajor;
31
+ let value = ok ? `${nodeVersion} (supported)` : nodeMajor === null ? `${nodeVersion} (unrecognized Node.js version)` : `${nodeVersion} (requires >= ${minMajor})`;
32
+ return {
33
+ diagnostic: {
34
+ nodeVersion,
35
+ nodeVersionValid: ok
36
+ },
37
+ check: {
38
+ name: 'Node.js',
39
+ value,
40
+ ok
41
+ }
42
+ };
43
+ }
44
+ function parseNodeMajorVersion(nodeVersion) {
45
+ let match = /^v?(\d+)\.\d+\.\d+$/.exec(nodeVersion);
46
+ return match ? Number(match[1]) : null;
47
+ }
48
+ export function getApiUrlCheck(apiUrl) {
49
+ try {
50
+ let url = new URL(apiUrl);
51
+ if (!['http:', 'https:'].includes(url.protocol)) {
52
+ throw new Error('URL must use http or https');
53
+ }
54
+ return {
55
+ apiUrl,
56
+ apiUrlValid: true,
57
+ check: {
58
+ name: 'API URL',
59
+ value: apiUrl,
60
+ ok: true
61
+ }
62
+ };
63
+ } catch {
64
+ return {
65
+ apiUrl,
66
+ apiUrlValid: false,
67
+ check: {
68
+ name: 'API URL',
69
+ value: 'invalid (check VIZZLY_API_URL)',
70
+ ok: false
71
+ }
72
+ };
73
+ }
74
+ }
75
+ export function getThresholdCheck(thresholdValue) {
76
+ let threshold = Number(thresholdValue);
77
+ // CIEDE2000 threshold: 0 = exact, 1 = JND, 2 = recommended, 3+ = permissive
78
+ let thresholdValid = Number.isFinite(threshold) && threshold >= 0;
79
+ return {
80
+ threshold,
81
+ thresholdValid,
82
+ check: thresholdValid ? {
83
+ name: 'Threshold',
84
+ value: `${threshold} (CIEDE2000)`,
85
+ ok: true
86
+ } : {
87
+ name: 'Threshold',
88
+ value: 'invalid',
89
+ ok: false
90
+ }
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Doctor command implementation - Run diagnostics to check environment
96
+ * @param {Object} options - Command options
97
+ * @param {Object} globalOptions - Global CLI options
98
+ * @param {Object} deps - Dependencies for testing
99
+ */
100
+ export async function doctorCommand(options = {}, globalOptions = {}, deps = {}) {
101
+ let {
102
+ createApiClient = defaultCreateApiClient,
103
+ getApiToken = defaultGetApiToken,
104
+ getBuilds = defaultGetBuilds,
105
+ getContext = defaultGetContext,
106
+ loadConfig = defaultLoadConfig,
107
+ nodeVersion = process.version,
108
+ output = defaultOutput,
109
+ exit = code => process.exit(code)
110
+ } = deps;
111
+ output.configure({
112
+ json: globalOptions.json,
113
+ verbose: globalOptions.verbose,
114
+ color: !globalOptions.noColor
115
+ });
116
+ let diagnostics = createDoctorDiagnostics();
38
117
  let hasErrors = false;
39
118
  let checks = [];
40
119
  try {
41
120
  // Determine if we'll attempt remote checks (API connectivity)
42
- let willCheckConnectivity = Boolean(options.api || getApiToken());
121
+ let hasApiToken = Boolean(getApiToken());
122
+ let willCheckConnectivity = Boolean(options.api || hasApiToken);
43
123
 
44
124
  // Show header
45
125
  output.header('doctor', willCheckConnectivity ? 'full' : 'local');
46
-
47
- // Node.js version check (require >= 20)
48
- let nodeVersion = process.version;
49
- let nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10);
50
- diagnostics.environment.nodeVersion = nodeVersion;
51
- diagnostics.environment.nodeVersionValid = nodeMajor >= 20;
52
- if (nodeMajor >= 20) {
53
- checks.push({
54
- name: 'Node.js',
55
- value: `${nodeVersion} (supported)`,
56
- ok: true
57
- });
58
- } else {
59
- checks.push({
60
- name: 'Node.js',
61
- value: `${nodeVersion} (requires >= 20)`,
62
- ok: false
63
- });
126
+ let nodeCheck = getNodeVersionCheck(nodeVersion);
127
+ diagnostics.environment = nodeCheck.diagnostic;
128
+ checks.push(nodeCheck.check);
129
+ if (!nodeCheck.check.ok) {
64
130
  hasErrors = true;
65
131
  }
66
132
 
67
133
  // Load configuration (apply global CLI overrides like --config only)
68
134
  let config = await loadConfig(globalOptions.config);
69
-
70
- // Validate apiUrl
71
- diagnostics.configuration.apiUrl = config.apiUrl;
72
- try {
73
- let url = new URL(config.apiUrl);
74
- if (!['http:', 'https:'].includes(url.protocol)) {
75
- throw new ConfigError('URL must use http or https');
76
- }
77
- diagnostics.configuration.apiUrlValid = true;
78
- checks.push({
79
- name: 'API URL',
80
- value: config.apiUrl,
81
- ok: true
82
- });
83
- } catch (_e) {
84
- diagnostics.configuration.apiUrlValid = false;
85
- checks.push({
86
- name: 'API URL',
87
- value: 'invalid (check VIZZLY_API_URL)',
88
- ok: false
89
- });
135
+ let apiUrlCheck = getApiUrlCheck(config.apiUrl);
136
+ diagnostics.configuration.apiUrl = apiUrlCheck.apiUrl;
137
+ diagnostics.configuration.apiUrlValid = apiUrlCheck.apiUrlValid;
138
+ checks.push(apiUrlCheck.check);
139
+ if (!apiUrlCheck.check.ok) {
90
140
  hasErrors = true;
91
141
  }
92
-
93
- // Validate threshold (0..1 inclusive)
94
- let threshold = Number(config?.comparison?.threshold);
95
- diagnostics.configuration.threshold = threshold;
96
- // CIEDE2000 threshold: 0 = exact, 1 = JND, 2 = recommended, 3+ = permissive
97
- let thresholdValid = Number.isFinite(threshold) && threshold >= 0;
98
- diagnostics.configuration.thresholdValid = thresholdValid;
99
- if (thresholdValid) {
100
- checks.push({
101
- name: 'Threshold',
102
- value: `${threshold} (CIEDE2000)`,
103
- ok: true
104
- });
105
- } else {
106
- checks.push({
107
- name: 'Threshold',
108
- value: 'invalid',
109
- ok: false
110
- });
142
+ let thresholdCheck = getThresholdCheck(config?.comparison?.threshold);
143
+ diagnostics.configuration.threshold = thresholdCheck.threshold;
144
+ diagnostics.configuration.thresholdValid = thresholdCheck.thresholdValid;
145
+ checks.push(thresholdCheck.check);
146
+ if (!thresholdCheck.check.ok) {
111
147
  hasErrors = true;
112
148
  }
113
149
 
@@ -121,8 +157,7 @@ export async function doctorCommand(options = {}, globalOptions = {}) {
121
157
  });
122
158
 
123
159
  // Optional: API connectivity check when --api is provided or VIZZLY_TOKEN is present
124
- let autoApi = Boolean(getApiToken());
125
- if (options.api || autoApi) {
160
+ if (willCheckConnectivity) {
126
161
  diagnostics.connectivity.checked = true;
127
162
  if (!config.apiKey) {
128
163
  diagnostics.connectivity.ok = false;
@@ -227,7 +262,7 @@ export async function doctorCommand(options = {}, globalOptions = {}) {
227
262
  output.error('Failed to run preflight', error);
228
263
  } finally {
229
264
  output.cleanup();
230
- if (hasErrors) process.exit(1);
265
+ if (hasErrors) exit(1);
231
266
  }
232
267
  }
233
268
 
@@ -6,79 +6,111 @@ import { VizzlyError } from '../errors/vizzly-error.js';
6
6
  import { loadPlugins } from '../plugin-loader.js';
7
7
  import { loadConfig } from '../utils/config-loader.js';
8
8
  import * as output from '../utils/output.js';
9
-
10
- /**
11
- * Simple configuration setup for Vizzly CLI
12
- */
13
- export class InitCommand {
14
- constructor(plugins = []) {
15
- this.plugins = plugins;
9
+ let configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(z.string(), configValueSchema)]));
10
+ let configSchemaValidator = z.record(z.string(), configValueSchema);
11
+ function createInitDeps(deps = {}) {
12
+ return {
13
+ access: deps.access || fs.access,
14
+ cwd: deps.cwd || (() => process.cwd()),
15
+ loadConfig: deps.loadConfig || loadConfig,
16
+ loadPlugins: deps.loadPlugins || loadPlugins,
17
+ output: deps.output || output,
18
+ writeFile: deps.writeFile || fs.writeFile
19
+ };
20
+ }
21
+ function configureOutput(output, options) {
22
+ output.configure({
23
+ json: options.json || false,
24
+ verbose: options.verbose || false,
25
+ color: options.color !== false
26
+ });
27
+ }
28
+ export function getInitConfigPath(cwd = process.cwd()) {
29
+ return path.join(cwd, 'vizzly.config.js');
30
+ }
31
+ export async function fileExists(filePath, access = fs.access) {
32
+ try {
33
+ await access(filePath);
34
+ return true;
35
+ } catch {
36
+ return false;
16
37
  }
17
- async run(options = {}) {
18
- // Check for existing config
19
- let configPath = path.join(process.cwd(), 'vizzly.config.js');
20
- let hasConfig = await this.fileExists(configPath);
21
- if (hasConfig && !options.force) {
22
- // JSON output for skipped
23
- if (options.json) {
24
- output.data({
25
- status: 'skipped',
26
- reason: 'config_exists',
27
- configPath,
28
- message: 'A vizzly.config.js file already exists. Use --force to overwrite.'
29
- });
30
- return {
31
- status: 'skipped',
32
- configPath
33
- };
34
- }
35
- output.header('init');
36
- output.warn('A vizzly.config.js file already exists');
37
- output.hint('Use --force to overwrite');
38
- return {
39
- status: 'skipped',
40
- configPath
41
- };
38
+ }
39
+ export function getPluginsWithConfig(plugins = []) {
40
+ return plugins.filter(plugin => plugin.configSchema);
41
+ }
42
+ export function getPluginConfigNames(plugins = []) {
43
+ return getPluginsWithConfig(plugins).map(plugin => plugin.name);
44
+ }
45
+ function formatObjectKey(key) {
46
+ if (/^[A-Za-z_$][\w$]*$/.test(key)) {
47
+ return key;
48
+ }
49
+ return formatValue(key);
50
+ }
51
+ export function formatValue(value, depth = 0) {
52
+ let indent = ' '.repeat(depth);
53
+ let nextIndent = ' '.repeat(depth + 1);
54
+ if (value === null) return 'null';
55
+ if (value === undefined) return 'undefined';
56
+ if (typeof value === 'string') {
57
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`;
58
+ }
59
+ if (typeof value === 'number' || typeof value === 'boolean') {
60
+ return String(value);
61
+ }
62
+ if (Array.isArray(value)) {
63
+ if (value.length === 0) return '[]';
64
+ let items = value.map(item => {
65
+ let formatted = formatValue(item, depth + 1);
66
+ return `${nextIndent}${formatted}`;
67
+ });
68
+ return `[\n${items.join(',\n')}\n${indent}]`;
69
+ }
70
+ if (typeof value === 'object') {
71
+ let entries = Object.entries(value);
72
+ if (entries.length === 0) return '{}';
73
+ let props = entries.map(([key, entryValue]) => {
74
+ let formatted = formatValue(entryValue, depth + 1);
75
+ return `${nextIndent}${formatObjectKey(key)}: ${formatted}`;
76
+ });
77
+ return `{\n${props.join(',\n')}\n${indent}}`;
78
+ }
79
+ return String(value);
80
+ }
81
+ export function formatPluginConfig(plugin, output = null) {
82
+ try {
83
+ configSchemaValidator.parse(plugin.configSchema);
84
+ let configEntries = [];
85
+ for (let [key, value] of Object.entries(plugin.configSchema)) {
86
+ let formattedValue = formatValue(value, 1);
87
+ configEntries.push(` // ${plugin.name} plugin configuration\n ${formatObjectKey(key)}: ${formattedValue}`);
42
88
  }
43
- try {
44
- // Generate config file with defaults
45
- await this.generateConfigFile(configPath, options);
46
-
47
- // Get plugins with config for JSON output
48
- let pluginsWithConfig = this.plugins.filter(p => p.configSchema);
49
- let pluginNames = pluginsWithConfig.map(p => p.name);
50
-
51
- // JSON output for success
52
- if (options.json) {
53
- output.data({
54
- status: 'created',
55
- configPath,
56
- plugins: pluginNames
57
- });
58
- return {
59
- status: 'created',
60
- configPath,
61
- plugins: pluginNames
62
- };
89
+ return configEntries.join(',\n\n');
90
+ } catch (error) {
91
+ if (error instanceof z.ZodError) {
92
+ let messages = error.issues.map(zodError => `${zodError.path.join('.')}: ${zodError.message}`);
93
+ output?.warn(`Invalid config schema for plugin ${plugin.name}: ${messages.join(', ')}`);
94
+ } else {
95
+ output?.warn(`Failed to format config for plugin ${plugin.name}: ${error.message}`);
96
+ }
97
+ return '';
98
+ }
99
+ }
100
+ export function generatePluginConfigs(plugins = [], output = null) {
101
+ let sections = [];
102
+ for (let plugin of plugins) {
103
+ if (plugin.configSchema) {
104
+ let configStr = formatPluginConfig(plugin, output);
105
+ if (configStr) {
106
+ sections.push(configStr);
63
107
  }
64
-
65
- // Show next steps
66
- this.showNextSteps();
67
- output.blank();
68
- output.complete('Vizzly CLI setup complete');
69
- return {
70
- status: 'created',
71
- configPath,
72
- plugins: pluginNames
73
- };
74
- } catch (error) {
75
- throw new VizzlyError('Failed to initialize Vizzly configuration', 'INIT_FAILED', {
76
- error: error.message
77
- });
78
108
  }
79
109
  }
80
- async generateConfigFile(configPath, options = {}) {
81
- let coreConfig = `export default {
110
+ return sections.length > 0 ? sections.join(',\n\n') : '';
111
+ }
112
+ export function createConfigContent(plugins = [], output = null) {
113
+ let coreConfig = `export default {
82
114
  // Server configuration (for run command)
83
115
  server: {
84
116
  port: 47392,
@@ -100,155 +132,124 @@ export class InitCommand {
100
132
 
101
133
  // Comparison configuration (CIEDE2000 Delta E: 0=exact, 1=JND, 2=recommended)
102
134
  comparison: {
103
- threshold: 2.0
135
+ threshold: 2.0,
136
+ minClusterSize: 2
104
137
  },
105
138
 
106
139
  // TDD configuration
107
140
  tdd: {
108
141
  openReport: false // Whether to auto-open HTML report in browser
109
142
  }`;
110
-
111
- // Add plugin configurations
112
- const pluginConfigs = this.generatePluginConfigs();
113
- if (pluginConfigs) {
114
- coreConfig += `,\n\n${pluginConfigs}`;
115
- }
116
- coreConfig += '\n};\n';
117
- await fs.writeFile(configPath, coreConfig, 'utf8');
118
-
119
- // Skip human-readable output in JSON mode
120
- if (options.json) return;
121
- output.header('init');
122
- output.complete('Created vizzly.config.js');
123
-
124
- // Log discovered plugins
125
- let pluginsWithConfig = this.plugins.filter(p => p.configSchema);
126
- if (pluginsWithConfig.length > 0) {
127
- output.hint(`Added config for ${pluginsWithConfig.length} plugin(s):`);
128
- output.list(pluginsWithConfig.map(p => p.name), {
129
- indent: 4
130
- });
131
- }
143
+ let pluginConfigs = generatePluginConfigs(plugins, output);
144
+ if (pluginConfigs) {
145
+ coreConfig += `,\n\n${pluginConfigs}`;
132
146
  }
133
-
134
- /**
135
- * Generate configuration sections for plugins
136
- * @returns {string} Plugin config sections as formatted string
137
- */
138
- generatePluginConfigs() {
139
- const sections = [];
140
- for (const plugin of this.plugins) {
141
- if (plugin.configSchema) {
142
- const configStr = this.formatPluginConfig(plugin);
143
- if (configStr) {
144
- sections.push(configStr);
145
- }
146
- }
147
- }
148
- return sections.length > 0 ? sections.join(',\n\n') : '';
149
- }
150
-
151
- /**
152
- * Format a plugin's config schema as JavaScript code
153
- * @param {Object} plugin - Plugin with configSchema
154
- * @returns {string} Formatted config string
155
- */
156
- formatPluginConfig(plugin) {
157
- try {
158
- // Validate config schema structure with Zod (defensive check)
159
- const configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
160
- const configSchemaValidator = z.record(configValueSchema);
161
- configSchemaValidator.parse(plugin.configSchema);
162
- const configEntries = [];
163
- for (const [key, value] of Object.entries(plugin.configSchema)) {
164
- const formattedValue = this.formatValue(value, 1);
165
- configEntries.push(` // ${plugin.name} plugin configuration\n ${key}: ${formattedValue}`);
166
- }
167
- return configEntries.join(',\n\n');
168
- } catch (error) {
169
- if (error instanceof z.ZodError) {
170
- const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
171
- output.warn(`Invalid config schema for plugin ${plugin.name}: ${messages.join(', ')}`);
172
- } else {
173
- output.warn(`Failed to format config for plugin ${plugin.name}: ${error.message}`);
174
- }
175
- return '';
176
- }
147
+ return `${coreConfig}\n};\n`;
148
+ }
149
+ function writeHumanCreatedOutput(output, plugins) {
150
+ output.header('init');
151
+ output.complete('Created vizzly.config.js');
152
+ let pluginsWithConfig = getPluginsWithConfig(plugins);
153
+ if (pluginsWithConfig.length > 0) {
154
+ output.hint(`Added config for ${pluginsWithConfig.length} plugin(s):`);
155
+ output.list(pluginsWithConfig.map(plugin => plugin.name), {
156
+ indent: 4
157
+ });
177
158
  }
178
-
179
- /**
180
- * Format a JavaScript value with proper indentation
181
- * @param {*} value - Value to format
182
- * @param {number} depth - Current indentation depth
183
- * @returns {string} Formatted value
184
- */
185
- formatValue(value, depth = 0) {
186
- const indent = ' '.repeat(depth);
187
- const nextIndent = ' '.repeat(depth + 1);
188
- if (value === null) return 'null';
189
- if (value === undefined) return 'undefined';
190
- if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
191
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
192
- if (Array.isArray(value)) {
193
- if (value.length === 0) return '[]';
194
- const items = value.map(item => {
195
- const formatted = this.formatValue(item, depth + 1);
196
- return `${nextIndent}${formatted}`;
197
- });
198
- return `[\n${items.join(',\n')}\n${indent}]`;
199
- }
200
- if (typeof value === 'object') {
201
- const entries = Object.entries(value);
202
- if (entries.length === 0) return '{}';
203
- const props = entries.map(([k, v]) => {
204
- const formatted = this.formatValue(v, depth + 1);
205
- return `${nextIndent}${k}: ${formatted}`;
206
- });
207
- return `{\n${props.join(',\n')}\n${indent}}`;
208
- }
209
- return String(value);
159
+ }
160
+ function showNextSteps(output) {
161
+ output.blank();
162
+ output.labelValue('Next steps', '');
163
+ output.list(['Set your API token: export VIZZLY_TOKEN="your-api-key"', 'Run your tests with Vizzly: npx vizzly run "npm test"', 'Upload screenshots: npx vizzly upload ./screenshots']);
164
+ }
165
+ async function writeConfigFile({
166
+ configPath,
167
+ plugins,
168
+ options,
169
+ output,
170
+ writeFile
171
+ }) {
172
+ let coreConfig = createConfigContent(plugins, output);
173
+ await writeFile(configPath, coreConfig, 'utf8');
174
+ if (!options.json) {
175
+ writeHumanCreatedOutput(output, plugins);
210
176
  }
211
- showNextSteps() {
212
- output.blank();
213
- output.labelValue('Next steps', '');
214
- output.list(['Set your API token: export VIZZLY_TOKEN="your-api-key"', 'Run your tests with Vizzly: npx vizzly run "npm test"', 'Upload screenshots: npx vizzly upload ./screenshots']);
177
+ }
178
+ async function loadInitPlugins(options, deps) {
179
+ if (options.plugins) {
180
+ return options.plugins;
215
181
  }
216
- async fileExists(filePath) {
217
- try {
218
- await fs.access(filePath);
219
- return true;
220
- } catch {
221
- return false;
222
- }
182
+ try {
183
+ let config = await deps.loadConfig(options.config, {});
184
+ return await deps.loadPlugins(options.config, config, null);
185
+ } catch {
186
+ return [];
223
187
  }
224
188
  }
225
189
 
226
190
  // Export factory function for CLI
227
191
  export function createInitCommand(options) {
228
- const command = new InitCommand(options.plugins);
229
- return () => command.run(options);
192
+ return () => init(options);
230
193
  }
231
-
232
- // Simple export for direct CLI usage
233
- export async function init(options = {}) {
234
- output.configure({
235
- json: options.json || false,
236
- verbose: options.verbose || false,
237
- color: options.color !== false
238
- });
239
- let plugins = [];
240
-
241
- // Try to load plugins if not provided
242
- if (!options.plugins) {
243
- try {
244
- const config = await loadConfig(options.config, {});
245
- plugins = await loadPlugins(options.config, config, null);
246
- } catch {
247
- // Silent fail - plugins are optional for init
194
+ export async function init(options = {}, deps = {}) {
195
+ let resolvedDeps = createInitDeps(deps);
196
+ configureOutput(resolvedDeps.output, options);
197
+ let plugins = await loadInitPlugins(options, resolvedDeps);
198
+ let configPath = getInitConfigPath(resolvedDeps.cwd());
199
+ let hasConfig = await fileExists(configPath, resolvedDeps.access);
200
+ if (hasConfig && !options.force) {
201
+ if (options.json) {
202
+ resolvedDeps.output.data({
203
+ status: 'skipped',
204
+ reason: 'config_exists',
205
+ configPath,
206
+ message: 'A vizzly.config.js file already exists. Use --force to overwrite.'
207
+ });
208
+ return {
209
+ status: 'skipped',
210
+ configPath
211
+ };
212
+ }
213
+ resolvedDeps.output.header('init');
214
+ resolvedDeps.output.warn('A vizzly.config.js file already exists');
215
+ resolvedDeps.output.hint('Use --force to overwrite');
216
+ return {
217
+ status: 'skipped',
218
+ configPath
219
+ };
220
+ }
221
+ try {
222
+ await writeConfigFile({
223
+ configPath,
224
+ plugins,
225
+ options,
226
+ output: resolvedDeps.output,
227
+ writeFile: resolvedDeps.writeFile
228
+ });
229
+ let pluginNames = getPluginConfigNames(plugins);
230
+ if (options.json) {
231
+ resolvedDeps.output.data({
232
+ status: 'created',
233
+ configPath,
234
+ plugins: pluginNames
235
+ });
236
+ return {
237
+ status: 'created',
238
+ configPath,
239
+ plugins: pluginNames
240
+ };
248
241
  }
249
- } else {
250
- plugins = options.plugins;
242
+ showNextSteps(resolvedDeps.output);
243
+ resolvedDeps.output.blank();
244
+ resolvedDeps.output.complete('Vizzly CLI setup complete');
245
+ return {
246
+ status: 'created',
247
+ configPath,
248
+ plugins: pluginNames
249
+ };
250
+ } catch (error) {
251
+ throw new VizzlyError('Failed to initialize Vizzly configuration', 'INIT_FAILED', {
252
+ error: error.message
253
+ });
251
254
  }
252
- const command = new InitCommand(plugins);
253
- return await command.run(options);
254
255
  }