@vizzly-testing/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -125,10 +131,10 @@ vizzly run "npm test" --parallel-id "ci-run-123" # For parallel CI builds
125
131
  For local visual testing with immediate feedback, use the dedicated `tdd` command:
126
132
 
127
133
  ```bash
128
- # Start interactive TDD dashboard
134
+ # Start interactive TDD dashboard (runs in background)
129
135
  vizzly tdd start
130
136
 
131
- # Run your tests in watch mode
137
+ # Run your tests in watch mode (same terminal or new one)
132
138
  npm test -- --watch
133
139
 
134
140
  # View the dashboard at http://localhost:47392
@@ -160,7 +166,7 @@ vizzly tdd stop
160
166
  - `--threshold <number>` - Comparison threshold (0-1, default: 0.1)
161
167
  - `--port <port>` - Server port (default: 47392)
162
168
  - `--timeout <ms>` - Server timeout (default: 30000)
163
- - `--daemon` - Run server in background (start command only)
169
+ - `--open` - Auto-open dashboard in browser (start command only)
164
170
 
165
171
  ### Setup and Status Commands
166
172
  ```bash
@@ -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
@@ -5,12 +5,55 @@ import { init } from './commands/init.js';
5
5
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
6
6
  import { runCommand, validateRunOptions } from './commands/run.js';
7
7
  import { tddCommand, validateTddOptions } from './commands/tdd.js';
8
- import { tddStartCommand, tddStopCommand, tddStatusCommand } from './commands/tdd-daemon.js';
8
+ import { tddStartCommand, tddStopCommand, tddStatusCommand, runDaemonChild } from './commands/tdd-daemon.js';
9
9
  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
+ try {
38
+ let plugins = await loadPlugins(configPath, config, logger);
39
+ for (let plugin of plugins) {
40
+ try {
41
+ // Add timeout protection for plugin registration (5 seconds)
42
+ let registerPromise = plugin.register(program, {
43
+ config,
44
+ logger,
45
+ services: container
46
+ });
47
+ let timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
48
+ await Promise.race([registerPromise, timeoutPromise]);
49
+ logger.debug(`Registered plugin: ${plugin.name}`);
50
+ } catch (error) {
51
+ logger.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
52
+ }
53
+ }
54
+ } catch (error) {
55
+ logger.debug(`Plugin loading failed: ${error.message}`);
56
+ }
14
57
  program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
15
58
  const globalOptions = program.opts();
16
59
  await init({
@@ -35,8 +78,14 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
35
78
  const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual comparisons');
36
79
 
37
80
  // TDD Start - Background server
38
- tddCmd.command('start').description('Start background TDD server').option('--port <port>', 'Port for screenshot server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').action(async options => {
81
+ tddCmd.command('start').description('Start background TDD server').option('--port <port>', 'Port for screenshot server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
39
82
  const globalOptions = program.opts();
83
+
84
+ // If this is a daemon child process, run the server directly
85
+ if (options.daemonChild) {
86
+ await runDaemonChild(options, globalOptions);
87
+ return;
88
+ }
40
89
  await tddStartCommand(options, globalOptions);
41
90
  });
42
91
 
@@ -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>}
@@ -38,15 +38,10 @@ export class InitCommand {
38
38
  }
39
39
  async generateConfigFile(configPath) {
40
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
-
45
41
  // Server configuration (for run command)
46
42
  server: {
47
43
  port: 47392,
48
- timeout: 30000,
49
- screenshotPath: '/screenshot'
44
+ timeout: 30000
50
45
  },
51
46
 
52
47
  // Build configuration
@@ -1,4 +1,4 @@
1
- import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
1
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, openSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { ConsoleUI } from '../utils/console-ui.js';
@@ -36,17 +36,76 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
36
36
  }
37
37
  const port = options.port || 47392;
38
38
 
39
- // Use existing tddCommand but with daemon mode - this will start and keep running
39
+ // Prepare log files for daemon output
40
+ const logFile = join(vizzlyDir, 'daemon.log');
41
+ const errorFile = join(vizzlyDir, 'daemon-error.log');
42
+
43
+ // Spawn detached child process to run the server
44
+ const child = spawn(process.execPath, [process.argv[1],
45
+ // CLI entry point
46
+ 'tdd', 'start', '--daemon-child',
47
+ // Special flag for child process
48
+ '--port', port.toString(), ...(options.open ? ['--open'] : []), ...(options.baselineBuild ? ['--baseline-build', options.baselineBuild] : []), ...(options.baselineComparison ? ['--baseline-comparison', options.baselineComparison] : []), ...(options.environment ? ['--environment', options.environment] : []), ...(options.threshold !== undefined ? ['--threshold', options.threshold.toString()] : []), ...(options.timeout ? ['--timeout', options.timeout] : []), ...(options.token ? ['--token', options.token] : []), ...(globalOptions.json ? ['--json'] : []), ...(globalOptions.verbose ? ['--verbose'] : []), ...(globalOptions.noColor ? ['--no-color'] : [])], {
49
+ detached: true,
50
+ stdio: ['ignore', openSync(logFile, 'a'), openSync(errorFile, 'a')],
51
+ cwd: process.cwd()
52
+ });
53
+
54
+ // Unref so parent can exit
55
+ child.unref();
56
+
57
+ // Verify server started with retries
58
+ const maxRetries = 10;
59
+ const retryDelay = 200; // Start with 200ms
60
+ let running = false;
61
+ for (let i = 0; i < maxRetries && !running; i++) {
62
+ await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
63
+ running = await isServerRunning(port);
64
+ }
65
+ if (!running) {
66
+ ui.error('Failed to start TDD server - server not responding to health checks');
67
+ process.exit(1);
68
+ }
69
+ ui.success(`TDD server started at http://localhost:${port}`);
70
+ ui.info('');
71
+ ui.info('Dashboard URLs:');
72
+ ui.info(` Comparisons: http://localhost:${port}/`);
73
+ ui.info(` Stats: http://localhost:${port}/stats`);
74
+ ui.info('');
75
+ ui.info('Next steps:');
76
+ ui.info(' 1. Run your tests (any test runner)');
77
+ ui.info(' 2. Open the dashboard in your browser');
78
+ ui.info(' 3. Manage baselines in the Stats view');
79
+ ui.info('');
80
+ ui.info('Stop server: npx vizzly tdd stop');
81
+ if (options.open) {
82
+ openDashboard(port);
83
+ }
84
+ } catch (error) {
85
+ ui.error('Failed to start TDD daemon', error);
86
+ process.exit(1);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Internal function to run server in child process
92
+ * This is called when --daemon-child flag is present
93
+ * @private
94
+ */
95
+ export async function runDaemonChild(options = {}, globalOptions = {}) {
96
+ const vizzlyDir = join(process.cwd(), '.vizzly');
97
+ const port = options.port || 47392;
98
+ try {
99
+ // Use existing tddCommand but with daemon mode
40
100
  const {
41
101
  cleanup
42
102
  } = await tddCommand(null,
43
103
  // No test command - server only
44
104
  {
45
105
  ...options,
46
- daemon: true // Flag to indicate daemon mode
106
+ daemon: true
47
107
  }, globalOptions);
48
108
 
49
- // The server is now running in this process
50
109
  // Store our PID for the stop command
51
110
  const pidFile = join(vizzlyDir, 'server.pid');
52
111
  writeFileSync(pidFile, process.pid.toString());
@@ -56,25 +115,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
56
115
  startTime: Date.now()
57
116
  };
58
117
  writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
59
- ui.success(`TDD server started at http://localhost:${port}`);
60
- ui.info('');
61
- ui.info('Dashboard URLs:');
62
- ui.info(` Comparisons: http://localhost:${port}/`);
63
- ui.info(` Stats: http://localhost:${port}/stats`);
64
- ui.info('');
65
- ui.info('Next steps:');
66
- ui.info(' 1. Run your tests (any test runner)');
67
- ui.info(' 2. Open the dashboard in your browser');
68
- ui.info(' 3. Manage baselines in the Stats view');
69
- ui.info('');
70
- ui.info('Stop server: npx vizzly tdd stop');
71
- if (options.open) {
72
- openDashboard(port);
73
- }
74
118
 
75
119
  // Set up graceful shutdown
76
- const handleShutdown = async signal => {
77
- ui.info(`\nReceived ${signal}, shutting down gracefully...`);
120
+ const handleShutdown = async () => {
78
121
  try {
79
122
  // Clean up PID files
80
123
  if (existsSync(pidFile)) unlinkSync(pidFile);
@@ -83,21 +126,28 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
83
126
 
84
127
  // Use the cleanup function from tddCommand
85
128
  await cleanup();
86
- ui.success('TDD server stopped');
87
- } catch (error) {
88
- ui.error('Error during shutdown:', error);
129
+ } catch {
130
+ // Silent cleanup in daemon
89
131
  }
90
132
  process.exit(0);
91
133
  };
92
134
 
93
135
  // Register signal handlers
94
- process.on('SIGINT', () => handleShutdown('SIGINT'));
95
- process.on('SIGTERM', () => handleShutdown('SIGTERM'));
136
+ process.on('SIGINT', () => handleShutdown());
137
+ process.on('SIGTERM', () => handleShutdown());
96
138
 
97
139
  // Keep process alive
98
140
  process.stdin.resume();
99
141
  } catch (error) {
100
- ui.error('Failed to start TDD daemon', error);
142
+ // Log error to file for debugging
143
+ const logFile = join(vizzlyDir, 'daemon-error.log');
144
+ try {
145
+ writeFileSync(logFile, `[${new Date().toISOString()}] ${error.stack || error}\n`, {
146
+ flag: 'a'
147
+ });
148
+ } catch {
149
+ // Silent failure if we can't write log
150
+ }
101
151
  process.exit(1);
102
152
  }
103
153
  }
@@ -0,0 +1,183 @@
1
+ import { glob } from 'glob';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve, dirname } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+
6
+ /**
7
+ * Load and register plugins from node_modules and config
8
+ * @param {string|null} configPath - Path to config file
9
+ * @param {Object} config - Loaded configuration
10
+ * @param {Object} logger - Logger instance
11
+ * @returns {Promise<Array>} Array of loaded plugins
12
+ */
13
+ export async function loadPlugins(configPath, config, logger) {
14
+ let plugins = [];
15
+ let loadedNames = new Set();
16
+
17
+ // 1. Auto-discover plugins from @vizzly-testing/* packages
18
+ let discoveredPlugins = await discoverInstalledPlugins(logger);
19
+ for (let pluginInfo of discoveredPlugins) {
20
+ try {
21
+ let plugin = await loadPlugin(pluginInfo.path, logger);
22
+ if (plugin && !loadedNames.has(plugin.name)) {
23
+ plugins.push(plugin);
24
+ loadedNames.add(plugin.name);
25
+ logger.debug(`Loaded plugin: ${plugin.name}@${plugin.version || 'unknown'}`);
26
+ }
27
+ } catch (error) {
28
+ logger.warn(`Failed to load auto-discovered plugin from ${pluginInfo.packageName}: ${error.message}`);
29
+ }
30
+ }
31
+
32
+ // 2. Load explicit plugins from config
33
+ if (config?.plugins && Array.isArray(config.plugins)) {
34
+ for (let pluginSpec of config.plugins) {
35
+ try {
36
+ let pluginPath = resolvePluginPath(pluginSpec, configPath);
37
+ let plugin = await loadPlugin(pluginPath, logger);
38
+ if (plugin && !loadedNames.has(plugin.name)) {
39
+ plugins.push(plugin);
40
+ loadedNames.add(plugin.name);
41
+ logger.debug(`Loaded plugin from config: ${plugin.name}@${plugin.version || 'unknown'}`);
42
+ } else if (plugin && loadedNames.has(plugin.name)) {
43
+ let existingPlugin = plugins.find(p => p.name === plugin.name);
44
+ logger.warn(`Plugin ${plugin.name} already loaded (v${existingPlugin.version || 'unknown'}), ` + `skipping v${plugin.version || 'unknown'} from config`);
45
+ }
46
+ } catch (error) {
47
+ logger.warn(`Failed to load plugin from config (${pluginSpec}): ${error.message}`);
48
+ }
49
+ }
50
+ }
51
+ return plugins;
52
+ }
53
+
54
+ /**
55
+ * Discover installed plugins from node_modules/@vizzly-testing/*
56
+ * @param {Object} logger - Logger instance
57
+ * @returns {Promise<Array>} Array of plugin info objects
58
+ */
59
+ async function discoverInstalledPlugins(logger) {
60
+ let plugins = [];
61
+ try {
62
+ // Find all @vizzly-testing packages
63
+ let packageJsonPaths = await glob('node_modules/@vizzly-testing/*/package.json', {
64
+ cwd: process.cwd(),
65
+ absolute: true
66
+ });
67
+ for (let pkgPath of packageJsonPaths) {
68
+ try {
69
+ let packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
70
+
71
+ // Check if package has a plugin field
72
+ if (packageJson.vizzly?.plugin) {
73
+ let pluginRelativePath = packageJson.vizzly.plugin;
74
+
75
+ // Security: Ensure plugin path is relative and doesn't traverse up
76
+ if (pluginRelativePath.startsWith('/') || pluginRelativePath.includes('..')) {
77
+ logger.warn(`Invalid plugin path in ${packageJson.name}: path must be relative and cannot traverse directories`);
78
+ continue;
79
+ }
80
+
81
+ // Resolve plugin path relative to package directory
82
+ let packageDir = dirname(pkgPath);
83
+ let pluginPath = resolve(packageDir, pluginRelativePath);
84
+
85
+ // Additional security: Ensure resolved path is still within package directory
86
+ if (!pluginPath.startsWith(packageDir)) {
87
+ logger.warn(`Plugin path escapes package directory: ${packageJson.name}`);
88
+ continue;
89
+ }
90
+ plugins.push({
91
+ packageName: packageJson.name,
92
+ path: pluginPath
93
+ });
94
+ }
95
+ } catch (error) {
96
+ logger.warn(`Failed to parse package.json at ${pkgPath}: ${error.message}`);
97
+ }
98
+ }
99
+ } catch (error) {
100
+ logger.debug(`Failed to discover plugins: ${error.message}`);
101
+ }
102
+ return plugins;
103
+ }
104
+
105
+ /**
106
+ * Load a plugin from a file path
107
+ * @param {string} pluginPath - Path to plugin file
108
+ * @returns {Promise<Object|null>} Loaded plugin or null
109
+ */
110
+ async function loadPlugin(pluginPath) {
111
+ try {
112
+ // Convert to file URL for ESM import
113
+ let pluginUrl = pathToFileURL(pluginPath).href;
114
+
115
+ // Dynamic import
116
+ let pluginModule = await import(pluginUrl);
117
+
118
+ // Get the default export
119
+ let plugin = pluginModule.default || pluginModule;
120
+
121
+ // Validate plugin structure
122
+ validatePluginStructure(plugin);
123
+ return plugin;
124
+ } catch (error) {
125
+ let newError = new Error(`Failed to load plugin from ${pluginPath}: ${error.message}`);
126
+ newError.cause = error;
127
+ throw newError;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Validate plugin has required structure
133
+ * @param {Object} plugin - Plugin object
134
+ * @throws {Error} If plugin structure is invalid
135
+ */
136
+ function validatePluginStructure(plugin) {
137
+ if (!plugin || typeof plugin !== 'object') {
138
+ throw new Error('Plugin must export an object');
139
+ }
140
+ if (!plugin.name || typeof plugin.name !== 'string') {
141
+ throw new Error('Plugin must have a name (string)');
142
+ }
143
+ if (!plugin.register || typeof plugin.register !== 'function') {
144
+ throw new Error('Plugin must have a register function');
145
+ }
146
+ if (plugin.version && typeof plugin.version !== 'string') {
147
+ throw new Error('Plugin version must be a string');
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Resolve plugin path from config
153
+ * @param {string} pluginSpec - Plugin specifier (package name or path)
154
+ * @param {string|null} configPath - Path to config file
155
+ * @returns {string} Resolved plugin path
156
+ */
157
+ function resolvePluginPath(pluginSpec, configPath) {
158
+ // If it's a package name (starts with @ or is alphanumeric), try to resolve from node_modules
159
+ if (pluginSpec.startsWith('@') || /^[a-zA-Z0-9-]+$/.test(pluginSpec)) {
160
+ // Try to resolve as a package
161
+ try {
162
+ let packageJsonPath = resolve(process.cwd(), 'node_modules', pluginSpec, 'package.json');
163
+ let packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
164
+ if (packageJson.vizzly?.plugin) {
165
+ let packageDir = dirname(packageJsonPath);
166
+ return resolve(packageDir, packageJson.vizzly.plugin);
167
+ }
168
+ throw new Error('Package does not specify a vizzly.plugin field');
169
+ } catch (error) {
170
+ throw new Error(`Cannot resolve plugin package ${pluginSpec}: ${error.message}`);
171
+ }
172
+ }
173
+
174
+ // Otherwise treat as a file path
175
+ if (configPath) {
176
+ // Resolve relative to config file
177
+ let configDir = dirname(configPath);
178
+ return resolve(configDir, pluginSpec);
179
+ } else {
180
+ // Resolve relative to cwd
181
+ return resolve(process.cwd(), pluginSpec);
182
+ }
183
+ }