@vizzly-testing/cli 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,13 @@
7
7
 
8
8
  ## What is Vizzly?
9
9
 
10
- Vizzly is a visual review platform designed for how modern teams work. Instead of recreating your components in a sandboxed environment, Vizzly captures screenshots directly from your functional tests. This means you test the *real thing*, not a snapshot.
10
+ Vizzly is a visual review platform designed for how modern teams work. Instead of recreating your
11
+ components in a sandboxed environment, Vizzly captures screenshots directly from your functional
12
+ tests. This means you test the *real thing*, not a snapshot.
11
13
 
12
- It's fast because we don't render anything—we process the images you provide from any source. Bring screenshots from web apps, mobile apps, or even design mockups, and use our collaborative dashboard to streamline the review process between developers and designers.
14
+ It's fast because we don't render anything—we process the images you provide from any source. Bring
15
+ screenshots from web apps, mobile apps, or even design mockups, and use our collaborative dashboard
16
+ to streamline the review process between developers and designers.
13
17
 
14
18
  ## Features
15
19
 
@@ -73,7 +77,9 @@ await vizzlyScreenshot('homepage', screenshot, {
73
77
  });
74
78
  ```
75
79
 
76
- > **Multi-Language Support**: Currently available as a JavaScript/Node.js SDK with Python, Ruby, and other language bindings coming soon. The client SDK is lightweight and simply POSTs screenshot data to the CLI for processing.
80
+ > **Multi-Language Support**: Currently available as a JavaScript/Node.js SDK with Python, Ruby, and
81
+ > other language bindings coming soon. The client SDK is lightweight and simply POSTs screenshot
82
+ > data to the CLI for processing.
77
83
 
78
84
  ## Commands
79
85
 
@@ -174,7 +180,8 @@ vizzly doctor --api # Include API connectivity checks
174
180
  ```
175
181
 
176
182
  #### Init Command
177
- Creates a basic `vizzly.config.js` configuration file with sensible defaults. No interactive prompts - just generates a clean config you can customize.
183
+ Creates a basic `vizzly.config.js` configuration file with sensible defaults. No interactive
184
+ prompts - just generates a clean config you can customize.
178
185
 
179
186
  ```bash
180
187
  vizzly init # Create config file
@@ -217,7 +224,8 @@ VIZZLY_TOKEN=your-token vizzly doctor --api
217
224
  vizzly doctor --json
218
225
  ```
219
226
 
220
- The dedicated `tdd` command provides fast local development with immediate visual feedback. See the [TDD Mode Guide](./docs/tdd-mode.md) for complete details on local visual testing.
227
+ The dedicated `tdd` command provides fast local development with immediate visual feedback. See the
228
+ [TDD Mode Guide](./docs/tdd-mode.md) for complete details on local visual testing.
221
229
 
222
230
  ## Configuration
223
231
 
@@ -225,20 +233,10 @@ Create a `vizzly.config.js` file with `vizzly init` or manually:
225
233
 
226
234
  ```javascript
227
235
  export default {
228
- // API configuration
229
- // Set VIZZLY_TOKEN environment variable or uncomment and set here:
230
- // apiToken: 'your-token-here',
231
-
232
- // Screenshot configuration
233
- screenshots: {
234
- directory: './screenshots',
235
- formats: ['png']
236
- },
237
-
238
236
  // Server configuration
239
237
  server: {
240
238
  port: 47392,
241
- screenshotPath: '/screenshot'
239
+ timeout: 30000
242
240
  },
243
241
 
244
242
  // Comparison configuration
@@ -368,14 +366,71 @@ Send a screenshot to Vizzly.
368
366
  ### `isVizzlyEnabled()`
369
367
  Check if Vizzly is enabled in the current environment.
370
368
 
369
+ ## Plugin Ecosystem
370
+
371
+ Vizzly supports a powerful plugin system that allows you to extend the CLI with custom
372
+ commands. Plugins are automatically discovered from `@vizzly-testing/*` packages or can be
373
+ explicitly configured.
374
+
375
+ ### Official Plugins
376
+
377
+ - **[@vizzly-testing/storybook](https://npmjs.com/package/@vizzly-testing/storybook)** *(coming
378
+ soon)* - Capture screenshots from Storybook builds
379
+
380
+ ### Using Plugins
381
+
382
+ Plugins under the `@vizzly-testing/*` scope are auto-discovered:
383
+
384
+ ```bash
385
+ # Install plugin
386
+ npm install @vizzly-testing/storybook
387
+
388
+ # Use immediately - commands are automatically available!
389
+ vizzly storybook ./storybook-static
390
+
391
+ # Plugin commands show in help
392
+ vizzly --help
393
+ ```
394
+
395
+ ### Creating Plugins
396
+
397
+ You can create your own plugins to add custom commands:
398
+
399
+ ```javascript
400
+ // plugin.js
401
+ export default {
402
+ name: 'my-plugin',
403
+ version: '1.0.0',
404
+ register(program, { config, logger, services }) {
405
+ program
406
+ .command('my-command')
407
+ .description('My custom command')
408
+ .action(async () => {
409
+ logger.info('Running my command!');
410
+ });
411
+ }
412
+ };
413
+ ```
414
+
415
+ Add to your `vizzly.config.js`:
416
+
417
+ ```javascript
418
+ export default {
419
+ plugins: ['./plugin.js']
420
+ };
421
+ ```
422
+
423
+ See the [Plugin Development Guide](./docs/plugins.md) for complete documentation and examples.
424
+
371
425
  ## Documentation
372
426
 
373
427
  - [Getting Started](./docs/getting-started.md)
374
428
  - [Upload Command Guide](./docs/upload-command.md)
375
429
  - [Test Integration Guide](./docs/test-integration.md)
376
430
  - [TDD Mode Guide](./docs/tdd-mode.md)
377
- - [API Reference](./docs/api-reference.md)
378
- - [Doctor Command](./docs/doctor-command.md)
431
+ - [Plugin Development](./docs/plugins.md)
432
+ - [API Reference](./docs/api-reference.md)
433
+ - [Doctor Command](./docs/doctor-command.md)
379
434
 
380
435
  ## Environment Variables
381
436
 
@@ -408,7 +463,8 @@ These variables take highest priority over both CLI arguments and automatic git
408
463
 
409
464
  ## Contributing
410
465
 
411
- We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation, your help makes Vizzly better for everyone.
466
+ We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation,
467
+ your help makes Vizzly better for everyone.
412
468
 
413
469
  ### Getting Started
414
470
 
@@ -437,7 +493,8 @@ Found a bug or have a feature request? Please [open an issue](https://github.com
437
493
 
438
494
  ### Development Setup
439
495
 
440
- The CLI is built with modern JavaScript and requires Node.js 20+ (LTS). See the development scripts in `package.json` for available commands.
496
+ The CLI is built with modern JavaScript and requires Node.js 20+ (LTS). See the development scripts
497
+ in `package.json` for available commands.
441
498
 
442
499
  ## License
443
500
 
package/dist/cli.js CHANGED
@@ -10,12 +10,57 @@ import { statusCommand, validateStatusOptions } from './commands/status.js';
10
10
  import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
11
11
  import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
12
12
  import { getPackageVersion } from './utils/package-info.js';
13
+ import { loadPlugins } from './plugin-loader.js';
14
+ import { loadConfig } from './utils/config-loader.js';
15
+ import { createComponentLogger } from './utils/logger-factory.js';
16
+ import { createServiceContainer } from './container/index.js';
13
17
  program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output').option('--json', 'Machine-readable output').option('--no-color', 'Disable colored output');
18
+
19
+ // Load plugins before defining commands
20
+ // We need to manually parse to get the config option early
21
+ let configPath = null;
22
+ let verboseMode = false;
23
+ for (let i = 0; i < process.argv.length; i++) {
24
+ if ((process.argv[i] === '-c' || process.argv[i] === '--config') && process.argv[i + 1]) {
25
+ configPath = process.argv[i + 1];
26
+ }
27
+ if (process.argv[i] === '-v' || process.argv[i] === '--verbose') {
28
+ verboseMode = true;
29
+ }
30
+ }
31
+ let config = await loadConfig(configPath, {});
32
+ let logger = createComponentLogger('CLI', {
33
+ level: config.logLevel || (verboseMode ? 'debug' : 'warn'),
34
+ verbose: verboseMode || false
35
+ });
36
+ let container = await createServiceContainer(config);
37
+ let plugins = [];
38
+ try {
39
+ plugins = await loadPlugins(configPath, config, logger);
40
+ for (let plugin of plugins) {
41
+ try {
42
+ // Add timeout protection for plugin registration (5 seconds)
43
+ let registerPromise = plugin.register(program, {
44
+ config,
45
+ logger,
46
+ services: container
47
+ });
48
+ let timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
49
+ await Promise.race([registerPromise, timeoutPromise]);
50
+ logger.debug(`Registered plugin: ${plugin.name}`);
51
+ } catch (error) {
52
+ logger.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
53
+ }
54
+ }
55
+ } catch (error) {
56
+ logger.debug(`Plugin loading failed: ${error.message}`);
57
+ }
14
58
  program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
15
59
  const globalOptions = program.opts();
16
60
  await init({
17
61
  ...globalOptions,
18
- ...options
62
+ ...options,
63
+ plugins
19
64
  });
20
65
  });
21
66
  program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
@@ -113,7 +113,6 @@ function createSimpleClient(serverUrl) {
113
113
  image: imageBuffer.toString('base64'),
114
114
  properties: options,
115
115
  threshold: options.threshold || 0,
116
- variant: options.variant,
117
116
  fullPage: options.fullPage || false
118
117
  })
119
118
  });
@@ -189,7 +188,6 @@ function createSimpleClient(serverUrl) {
189
188
  * @param {Object} [options] - Optional configuration
190
189
  * @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
191
190
  * @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
192
- * @param {string} [options.variant] - Variant name for organizing screenshots
193
191
  * @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot
194
192
  *
195
193
  * @returns {Promise<void>}
@@ -3,15 +3,19 @@ import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import { VizzlyError } from '../errors/vizzly-error.js';
5
5
  import { createComponentLogger } from '../utils/logger-factory.js';
6
+ import { loadPlugins } from '../plugin-loader.js';
7
+ import { loadConfig } from '../utils/config-loader.js';
8
+ import { z } from 'zod';
6
9
 
7
10
  /**
8
11
  * Simple configuration setup for Vizzly CLI
9
12
  */
10
13
  export class InitCommand {
11
- constructor(logger) {
14
+ constructor(logger, plugins = []) {
12
15
  this.logger = logger || createComponentLogger('INIT', {
13
16
  level: 'info'
14
17
  });
18
+ this.plugins = plugins;
15
19
  }
16
20
  async run(options = {}) {
17
21
  this.logger.info('🎯 Initializing Vizzly configuration...\n');
@@ -37,16 +41,11 @@ export class InitCommand {
37
41
  }
38
42
  }
39
43
  async generateConfigFile(configPath) {
40
- const configContent = `export default {
41
- // API configuration
42
- // Set VIZZLY_TOKEN environment variable or uncomment and set here:
43
- // apiKey: 'your-token-here',
44
-
44
+ let coreConfig = `export default {
45
45
  // Server configuration (for run command)
46
46
  server: {
47
47
  port: 47392,
48
- timeout: 30000,
49
- screenshotPath: '/screenshot'
48
+ timeout: 30000
50
49
  },
51
50
 
52
51
  // Build configuration
@@ -70,11 +69,103 @@ export class InitCommand {
70
69
  // TDD configuration
71
70
  tdd: {
72
71
  openReport: false // Whether to auto-open HTML report in browser
73
- }
74
- };
75
- `;
76
- await fs.writeFile(configPath, configContent, 'utf8');
72
+ }`;
73
+
74
+ // Add plugin configurations
75
+ let pluginConfigs = this.generatePluginConfigs();
76
+ if (pluginConfigs) {
77
+ coreConfig += ',\n\n' + pluginConfigs;
78
+ }
79
+ coreConfig += '\n};\n';
80
+ await fs.writeFile(configPath, coreConfig, 'utf8');
77
81
  this.logger.info(`📄 Created vizzly.config.js`);
82
+
83
+ // Log discovered plugins
84
+ let pluginsWithConfig = this.plugins.filter(p => p.configSchema);
85
+ if (pluginsWithConfig.length > 0) {
86
+ this.logger.info(` Added config for ${pluginsWithConfig.length} plugin(s):`);
87
+ pluginsWithConfig.forEach(p => {
88
+ this.logger.info(` - ${p.name}`);
89
+ });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Generate configuration sections for plugins
95
+ * @returns {string} Plugin config sections as formatted string
96
+ */
97
+ generatePluginConfigs() {
98
+ let sections = [];
99
+ for (let plugin of this.plugins) {
100
+ if (plugin.configSchema) {
101
+ let configStr = this.formatPluginConfig(plugin);
102
+ if (configStr) {
103
+ sections.push(configStr);
104
+ }
105
+ }
106
+ }
107
+ return sections.length > 0 ? sections.join(',\n\n') : '';
108
+ }
109
+
110
+ /**
111
+ * Format a plugin's config schema as JavaScript code
112
+ * @param {Object} plugin - Plugin with configSchema
113
+ * @returns {string} Formatted config string
114
+ */
115
+ formatPluginConfig(plugin) {
116
+ try {
117
+ // Validate config schema structure with Zod (defensive check)
118
+ let configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
119
+ let configSchemaValidator = z.record(configValueSchema);
120
+ configSchemaValidator.parse(plugin.configSchema);
121
+ let configEntries = [];
122
+ for (let [key, value] of Object.entries(plugin.configSchema)) {
123
+ let formattedValue = this.formatValue(value, 1);
124
+ configEntries.push(` // ${plugin.name} plugin configuration\n ${key}: ${formattedValue}`);
125
+ }
126
+ return configEntries.join(',\n\n');
127
+ } catch (error) {
128
+ if (error instanceof z.ZodError) {
129
+ let messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
130
+ this.logger.warn(`Invalid config schema for plugin ${plugin.name}: ${messages.join(', ')}`);
131
+ } else {
132
+ this.logger.warn(`Failed to format config for plugin ${plugin.name}: ${error.message}`);
133
+ }
134
+ return '';
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Format a JavaScript value with proper indentation
140
+ * @param {*} value - Value to format
141
+ * @param {number} depth - Current indentation depth
142
+ * @returns {string} Formatted value
143
+ */
144
+ formatValue(value, depth = 0) {
145
+ let indent = ' '.repeat(depth);
146
+ let nextIndent = ' '.repeat(depth + 1);
147
+ if (value === null) return 'null';
148
+ if (value === undefined) return 'undefined';
149
+ if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
150
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
151
+ if (Array.isArray(value)) {
152
+ if (value.length === 0) return '[]';
153
+ let items = value.map(item => {
154
+ let formatted = this.formatValue(item, depth + 1);
155
+ return `${nextIndent}${formatted}`;
156
+ });
157
+ return `[\n${items.join(',\n')}\n${indent}]`;
158
+ }
159
+ if (typeof value === 'object') {
160
+ let entries = Object.entries(value);
161
+ if (entries.length === 0) return '{}';
162
+ let props = entries.map(([k, v]) => {
163
+ let formatted = this.formatValue(v, depth + 1);
164
+ return `${nextIndent}${k}: ${formatted}`;
165
+ });
166
+ return `{\n${props.join(',\n')}\n${indent}}`;
167
+ }
168
+ return String(value);
78
169
  }
79
170
  showNextSteps() {
80
171
  this.logger.info('\n📚 Next steps:');
@@ -97,12 +188,28 @@ export class InitCommand {
97
188
 
98
189
  // Export factory function for CLI
99
190
  export function createInitCommand(options) {
100
- const command = new InitCommand(options.logger);
191
+ const command = new InitCommand(options.logger, options.plugins);
101
192
  return () => command.run(options);
102
193
  }
103
194
 
104
195
  // Simple export for direct CLI usage
105
196
  export async function init(options = {}) {
106
- const command = new InitCommand();
197
+ let plugins = [];
198
+
199
+ // Try to load plugins if not provided
200
+ if (!options.plugins) {
201
+ try {
202
+ let config = await loadConfig(options.config, {});
203
+ let logger = createComponentLogger('INIT', {
204
+ level: 'debug'
205
+ });
206
+ plugins = await loadPlugins(options.config, config, logger);
207
+ } catch {
208
+ // Silent fail - plugins are optional for init
209
+ }
210
+ } else {
211
+ plugins = options.plugins;
212
+ }
213
+ const command = new InitCommand(null, plugins);
107
214
  return await command.run(options);
108
215
  }
@@ -0,0 +1,204 @@
1
+ import { glob } from 'glob';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve, dirname } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import { z } from 'zod';
6
+
7
+ /**
8
+ * Load and register plugins from node_modules and config
9
+ * @param {string|null} configPath - Path to config file
10
+ * @param {Object} config - Loaded configuration
11
+ * @param {Object} logger - Logger instance
12
+ * @returns {Promise<Array>} Array of loaded plugins
13
+ */
14
+ export async function loadPlugins(configPath, config, logger) {
15
+ let plugins = [];
16
+ let loadedNames = new Set();
17
+
18
+ // 1. Auto-discover plugins from @vizzly-testing/* packages
19
+ let discoveredPlugins = await discoverInstalledPlugins(logger);
20
+ for (let pluginInfo of discoveredPlugins) {
21
+ try {
22
+ let plugin = await loadPlugin(pluginInfo.path, logger);
23
+ if (plugin && !loadedNames.has(plugin.name)) {
24
+ plugins.push(plugin);
25
+ loadedNames.add(plugin.name);
26
+ logger.debug(`Loaded plugin: ${plugin.name}@${plugin.version || 'unknown'}`);
27
+ }
28
+ } catch (error) {
29
+ logger.warn(`Failed to load auto-discovered plugin from ${pluginInfo.packageName}: ${error.message}`);
30
+ }
31
+ }
32
+
33
+ // 2. Load explicit plugins from config
34
+ if (config?.plugins && Array.isArray(config.plugins)) {
35
+ for (let pluginSpec of config.plugins) {
36
+ try {
37
+ let pluginPath = resolvePluginPath(pluginSpec, configPath);
38
+ let plugin = await loadPlugin(pluginPath, logger);
39
+ if (plugin && !loadedNames.has(plugin.name)) {
40
+ plugins.push(plugin);
41
+ loadedNames.add(plugin.name);
42
+ logger.debug(`Loaded plugin from config: ${plugin.name}@${plugin.version || 'unknown'}`);
43
+ } else if (plugin && loadedNames.has(plugin.name)) {
44
+ let existingPlugin = plugins.find(p => p.name === plugin.name);
45
+ logger.warn(`Plugin ${plugin.name} already loaded (v${existingPlugin.version || 'unknown'}), ` + `skipping v${plugin.version || 'unknown'} from config`);
46
+ }
47
+ } catch (error) {
48
+ logger.warn(`Failed to load plugin from config (${pluginSpec}): ${error.message}`);
49
+ }
50
+ }
51
+ }
52
+ return plugins;
53
+ }
54
+
55
+ /**
56
+ * Discover installed plugins from node_modules/@vizzly-testing/*
57
+ * @param {Object} logger - Logger instance
58
+ * @returns {Promise<Array>} Array of plugin info objects
59
+ */
60
+ async function discoverInstalledPlugins(logger) {
61
+ let plugins = [];
62
+ try {
63
+ // Find all @vizzly-testing packages
64
+ let packageJsonPaths = await glob('node_modules/@vizzly-testing/*/package.json', {
65
+ cwd: process.cwd(),
66
+ absolute: true
67
+ });
68
+ for (let pkgPath of packageJsonPaths) {
69
+ try {
70
+ let packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
71
+
72
+ // Check if package has a plugin field
73
+ if (packageJson.vizzly?.plugin) {
74
+ let pluginRelativePath = packageJson.vizzly.plugin;
75
+
76
+ // Security: Ensure plugin path is relative and doesn't traverse up
77
+ if (pluginRelativePath.startsWith('/') || pluginRelativePath.includes('..')) {
78
+ logger.warn(`Invalid plugin path in ${packageJson.name}: path must be relative and cannot traverse directories`);
79
+ continue;
80
+ }
81
+
82
+ // Resolve plugin path relative to package directory
83
+ let packageDir = dirname(pkgPath);
84
+ let pluginPath = resolve(packageDir, pluginRelativePath);
85
+
86
+ // Additional security: Ensure resolved path is still within package directory
87
+ if (!pluginPath.startsWith(packageDir)) {
88
+ logger.warn(`Plugin path escapes package directory: ${packageJson.name}`);
89
+ continue;
90
+ }
91
+ plugins.push({
92
+ packageName: packageJson.name,
93
+ path: pluginPath
94
+ });
95
+ }
96
+ } catch (error) {
97
+ logger.warn(`Failed to parse package.json at ${pkgPath}: ${error.message}`);
98
+ }
99
+ }
100
+ } catch (error) {
101
+ logger.debug(`Failed to discover plugins: ${error.message}`);
102
+ }
103
+ return plugins;
104
+ }
105
+
106
+ /**
107
+ * Load a plugin from a file path
108
+ * @param {string} pluginPath - Path to plugin file
109
+ * @returns {Promise<Object|null>} Loaded plugin or null
110
+ */
111
+ async function loadPlugin(pluginPath) {
112
+ try {
113
+ // Convert to file URL for ESM import
114
+ let pluginUrl = pathToFileURL(pluginPath).href;
115
+
116
+ // Dynamic import
117
+ let pluginModule = await import(pluginUrl);
118
+
119
+ // Get the default export
120
+ let plugin = pluginModule.default || pluginModule;
121
+
122
+ // Validate plugin structure
123
+ validatePluginStructure(plugin);
124
+ return plugin;
125
+ } catch (error) {
126
+ let newError = new Error(`Failed to load plugin from ${pluginPath}: ${error.message}`);
127
+ newError.cause = error;
128
+ throw newError;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Zod schema for validating plugin structure
134
+ */
135
+ const pluginSchema = z.object({
136
+ name: z.string().min(1, 'Plugin name is required'),
137
+ version: z.string().optional(),
138
+ register: z.function(z.tuple([z.any(), z.any()]), z.void()),
139
+ configSchema: z.record(z.any()).optional()
140
+ });
141
+
142
+ /**
143
+ * Zod schema for validating plugin config values
144
+ * Supports: string, number, boolean, null, arrays, and nested objects
145
+ */
146
+ const configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
147
+
148
+ /**
149
+ * Validate plugin has required structure
150
+ * @param {Object} plugin - Plugin object
151
+ * @throws {Error} If plugin structure is invalid
152
+ */
153
+ function validatePluginStructure(plugin) {
154
+ try {
155
+ // Validate basic plugin structure
156
+ pluginSchema.parse(plugin);
157
+
158
+ // If configSchema exists, validate it contains valid config values
159
+ if (plugin.configSchema) {
160
+ let configSchemaValidator = z.record(configValueSchema);
161
+ configSchemaValidator.parse(plugin.configSchema);
162
+ }
163
+ } catch (error) {
164
+ if (error instanceof z.ZodError) {
165
+ let messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
166
+ throw new Error(`Invalid plugin structure: ${messages.join(', ')}`);
167
+ }
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Resolve plugin path from config
174
+ * @param {string} pluginSpec - Plugin specifier (package name or path)
175
+ * @param {string|null} configPath - Path to config file
176
+ * @returns {string} Resolved plugin path
177
+ */
178
+ function resolvePluginPath(pluginSpec, configPath) {
179
+ // If it's a package name (starts with @ or is alphanumeric), try to resolve from node_modules
180
+ if (pluginSpec.startsWith('@') || /^[a-zA-Z0-9-]+$/.test(pluginSpec)) {
181
+ // Try to resolve as a package
182
+ try {
183
+ let packageJsonPath = resolve(process.cwd(), 'node_modules', pluginSpec, 'package.json');
184
+ let packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
185
+ if (packageJson.vizzly?.plugin) {
186
+ let packageDir = dirname(packageJsonPath);
187
+ return resolve(packageDir, packageJson.vizzly.plugin);
188
+ }
189
+ throw new Error('Package does not specify a vizzly.plugin field');
190
+ } catch (error) {
191
+ throw new Error(`Cannot resolve plugin package ${pluginSpec}: ${error.message}`);
192
+ }
193
+ }
194
+
195
+ // Otherwise treat as a file path
196
+ if (configPath) {
197
+ // Resolve relative to config file
198
+ let configDir = dirname(configPath);
199
+ return resolve(configDir, pluginSpec);
200
+ } else {
201
+ // Resolve relative to cwd
202
+ return resolve(process.cwd(), pluginSpec);
203
+ }
204
+ }