diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3fe462f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,74 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ name: Test on ${{ matrix.os }} / Node ${{ matrix.node }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ node: ['18', '20', '22']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run format check
+ run: npm run format:check
+
+ - name: Run unit tests
+ run: npm run test:unit
+
+ - name: Run integration tests
+ run: npm run test:integration
+
+ - name: Run E2E tests
+ run: npm run test:e2e
+ env:
+ ENABLE_NOTIFICATIONS: 'false'
+
+ coverage:
+ name: Coverage Report
+ runs-on: ubuntu-latest
+ needs: test
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests with coverage
+ run: npm run test:coverage
+ env:
+ ENABLE_NOTIFICATIONS: 'false'
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage/lcov.info
+ fail_ci_if_error: false
+ continue-on-error: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a77bb0d..c08b46c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,9 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.1.0] - 2026-01-12
+
+### Added
+
+- **Testing infrastructure**: Comprehensive test suite using Vitest
+ - Unit tests for pure functions (sanitizeServerName, parseArguments, detectPortFromOutput, getLogViewerCommand)
+ - Integration tests for config loading and health checks
+ - E2E tests for CLI commands and workflows
+- **CI/CD pipeline**: GitHub Actions workflow for automated testing
+ - Matrix testing on Ubuntu and macOS
+ - Node.js 18, 20, 22 support
+ - Code coverage reporting via Codecov
+- **Test utilities**: Helper modules for CLI execution, mock servers, and temp project setup
+- **ADR-002**: Architecture Decision Record documenting the Vitest testing strategy
+
+### Changed
+
+- Updated CLAUDE.md formatting for consistency
+
## [2.0.0] - 2026-01-09
### Changed
+
- **Breaking**: Complete architecture rewrite - now a self-contained TypeScript CLI
- **Breaking**: Switched from compiled output to running TypeScript directly via `tsx`
- **Breaking**: Removed modular `src/` architecture in favor of single `bin/dev.ts`
@@ -15,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified package structure for easier maintenance
### Added
+
- Native OS notifications via `node-notifier` for server events
- `doctor` command for environment diagnostics
- `restart` command for quick server restarts
@@ -23,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Exponential backoff for health checks
### Removed
+
- Compiled `dist/` output (no build step required)
- Modular `src/` directory (consolidated into `bin/dev.ts`)
- Test suite (tests were for old architecture)
@@ -30,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - 2024-09-19
### Added
+
- Initial release of dev
- Core server lifecycle management (start, stop, status)
- Process monitoring with PID tracking
@@ -44,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive documentation and examples
### Features
+
- **Commands**: init, start, stop, status, port, logs, cleanup
- **Configuration**: JSON-based server configuration
- **Monitoring**: Real-time process health monitoring
@@ -56,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Platform Support**: macOS and Linux
### Technical Details
+
- Built with ES modules
- Requires Node.js >=18.0.0
- Uses child_process, fs, path, and util built-ins
diff --git a/CLAUDE.md b/CLAUDE.md
index de95951..aa73bcd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,27 +22,32 @@ npm run format:check
The codebase is a self-contained TypeScript CLI:
### Core Files (`bin/`)
+
- **`dev.ts`** - Main CLI script (~1200 lines, all-in-one)
- **`notify.ts`** - Native OS notification utility using node-notifier
### Key Concepts
#### Server Configuration
+
- Servers are defined in `.dev/servers.json` with command, preferredPort, and healthCheck
- Template variables: `{PORT}` for dynamic port assignment, `{ROLE}` for server name
- Automatic server inference from package.json scripts during `init`
#### Process Management
+
- PID tracking in `.dev/pid.json` with port, startTime, and status
- Automatic port conflict resolution (tries ports sequentially)
- Health checks before marking servers as successfully started
- Process cleanup and stale entry detection
#### Log Management
+
- Centralized logging in `.dev/log/` directory
- Configurable log viewers via `--log-viewer` flag or `DEV_LOG_VIEWER` environment variable
### File Structure
+
```
bin/
├── dev.ts # Main CLI script
diff --git a/README.md b/README.md
index 4f2aa7f..171bb00 100644
--- a/README.md
+++ b/README.md
@@ -1,85 +1,74 @@
-# dev
+
+
+
[](https://badge.fury.io/js/@wilmoore%2Fdev)
-[](https://opensource.org/licenses/MIT)
[](https://nodejs.org/)
[](https://github.com/wilmoore/dev)
+[](https://opensource.org/licenses/MIT)
+
+**Manage multiple dev servers with health checks, port handling, and unified logs.**
-> A sophisticated development server management tool with process monitoring, health checks, and log management.
+
-
+---
## Features
-- **Intelligent Server Management** - Start, stop, and monitor multiple development servers
-- **Process Monitoring** - Real-time health checks and PID tracking
-- **Port Management** - Automatic port conflict resolution and detection
-- **Log Management** - Centralized logging with real-time log viewing
-- **Auto-Configuration** - Automatically infers server configurations from package.json
-- **Native Notifications** - OS-level notifications for server events
-- **Process Cleanup** - Automatic cleanup of stale processes
-- **Log Viewer Integration** - Customizable log viewers (tail, bat, etc.)
+- Start, stop, and monitor multiple servers from one command
+- Health checks with automatic retry and status reporting
+- Port conflict resolution — finds the next available port
+- Centralized logs in `.dev/log/` with configurable viewers
+- Auto-configuration from `package.json` scripts
+- Native OS notifications for server events
+- Automatic cleanup of stale processes
-## Quick Start
+## Why dev?
-### Installation
+Most projects juggle multiple servers: frontend, backend, workers. Running them manually means scattered terminals, forgotten processes, and port conflicts. `dev` gives you a single command to start everything, track what's running, and view logs in one place.
+
+## Quick Start
```bash
# Install globally
npm install -g @wilmoore/dev
-# Or use with npx (recommended)
+# Or run directly with npx
npx @wilmoore/dev init
```
-### Basic Usage
+Initialize in your project, then start:
```bash
-# Initialize in your project
-npx dev init
-
-# Start the first configured server
-npx dev start
-
-# Start a specific server
-npx dev start frontend
-
-# Check running servers
-npx dev status
-
-# View logs
-npx dev logs
-
-# Stop all servers
-npx dev stop
+npx dev init # Creates .dev/servers.json from package.json
+npx dev start # Starts the first configured server
+npx dev status # Shows running servers
+npx dev logs # Follows logs in real-time
+npx dev stop # Stops all servers
```
## Commands
-| Command | Description | Example |
-|---------|-------------|---------|
-| `init` | Initialize .dev directory from package.json | `npx dev init` |
-| `start [server]` | Start a server (default: first server) | `npx dev start frontend` |
-| `stop [server]` | Stop server(s) (default: all) | `npx dev stop backend` |
-| `restart [server]` | Restart a server | `npx dev restart api` |
-| `status` | Show running servers with health status | `npx dev status` |
-| `logs [server]` | Follow server logs in real-time | `npx dev logs api` |
-| `doctor` | Diagnose environment and show configuration | `npx dev doctor` |
-| `cleanup` | Remove stale entries from PID tracking | `npx dev cleanup` |
+| Command | Description | Example |
+| ------------------ | ---------------------------------------------- | ------------------------ |
+| `init` | Initialize `.dev/` directory from package.json | `npx dev init` |
+| `start [server]` | Start a server (default: first) | `npx dev start frontend` |
+| `stop [server]` | Stop server(s) (default: all) | `npx dev stop backend` |
+| `restart [server]` | Restart a server | `npx dev restart api` |
+| `status` | Show running servers with health status | `npx dev status` |
+| `logs [server]` | Follow server logs | `npx dev logs api` |
+| `doctor` | Diagnose environment and configuration | `npx dev doctor` |
+| `cleanup` | Remove stale PID entries | `npx dev cleanup` |
-### Shortcuts
-
-You can use server names directly as commands:
+**Shortcut:** Use server names directly as commands:
```bash
-# These are equivalent
-npx dev start frontend
-npx dev frontend
+npx dev frontend # Same as: npx dev start frontend
```
## Configuration
-The tool automatically creates a `.dev/servers.json` configuration file:
+The `init` command creates `.dev/servers.json`:
```json
{
@@ -96,95 +85,81 @@ The tool automatically creates a `.dev/servers.json` configuration file:
}
```
-### Configuration Options
+### Options
-- **command**: Shell command to start the server
- - `{PORT}`: Replaced with the assigned port
- - `{ROLE}`: Replaced with the server name
-- **preferredPort**: Starting port number (auto-increments if busy)
-- **healthCheck**: URL for health checking the server
+| Key | Description |
+| --------------- | ---------------------------------------------------------------- |
+| `command` | Shell command to run. Use `{PORT}` and `{ROLE}` as placeholders. |
+| `preferredPort` | Starting port. Auto-increments if busy. |
+| `healthCheck` | URL to poll until the server responds. |
### Environment Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `DEV_LOG_VIEWER` | Default log viewer command | `tail -f` |
-| `ENABLE_NOTIFICATIONS` | Enable/disable OS notifications | `true` |
-
-### CLI Options
-
-| Option | Description | Example |
-|--------|-------------|---------|
-| `--log-viewer "cmd"` | Custom log viewer command | `--log-viewer "bat -f"` |
-
-## Project Structure
-
-After initialization, your project will have:
-
-```
-your-project/
-├── .dev/
-│ ├── servers.json # Server configurations (tracked in git)
-│ ├── pid.json # Running process tracking (gitignored)
-│ └── log/ # Server log files (gitignored)
-│ ├── frontend.log
-│ └── backend.log
-└── package.json
-```
+| Variable | Description | Default |
+| ---------------------- | ------------------------ | --------- |
+| `DEV_LOG_VIEWER` | Command for viewing logs | `tail -f` |
+| `ENABLE_NOTIFICATIONS` | Enable OS notifications | `true` |
## Advanced Usage
### Custom Log Viewers
```bash
-# Use bat for syntax highlighting
-npx dev start frontend --log-viewer "bat -f"
-
-# Use less for scrollable logs
-npx dev start api --log-viewer "less +F"
+npx dev logs --log-viewer "bat -f" # Syntax highlighting
+npx dev logs --log-viewer "less +F" # Scrollable
-# Set default via environment
+# Or set a default
export DEV_LOG_VIEWER="bat -f"
-npx dev start
```
### Template Variables
-Use `{ROLE}` and `{PORT}` template variables for dynamic configuration:
+Use `{PORT}` and `{ROLE}` for dynamic values:
```json
{
- "multi-env": {
- "command": "NODE_ENV={ROLE} npm start --port {PORT} > .dev/log/{ROLE}.log 2>&1",
- "preferredPort": 3000,
+ "worker": {
+ "command": "NODE_ENV={ROLE} node worker.js --port {PORT} > .dev/log/{ROLE}.log 2>&1",
+ "preferredPort": 4000,
"healthCheck": "http://localhost:{PORT}/health"
}
}
```
-### Port Management
+### Port Handling
-The tool automatically handles port conflicts:
+If the preferred port is busy, `dev` tries the next one:
-```bash
-# If port 3000 is busy, it tries 3001, 3002, etc.
-npx dev start dev
-# Started on port 3001 (3000 was busy)
+```
+$ npx dev start api
+Started api on port 3001 (3000 was busy)
```
-### Disabling Notifications
+### Disable Notifications
```bash
-# Disable OS notifications
ENABLE_NOTIFICATIONS=false npx dev start
```
+## Project Structure
+
+After initialization:
+
+```
+your-project/
+├── .dev/
+│ ├── servers.json # Server config (commit this)
+│ ├── pid.json # Process tracking (gitignored)
+│ └── log/ # Log files (gitignored)
+└── package.json
+```
+
## License
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+MIT — see [LICENSE](LICENSE) for details.
---
- Made with care for developers who love efficient workflows
+ Built for developers who value efficient workflows.
diff --git a/bin/dev.ts b/bin/dev.ts
index 3bdf5db..090f393 100755
--- a/bin/dev.ts
+++ b/bin/dev.ts
@@ -1,187 +1,202 @@
#!/usr/bin/env tsx
-import { spawn, exec, ChildProcess } from 'child_process'
-import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs'
-import { networkInterfaces } from 'os'
-import path from 'path'
-import { fileURLToPath } from 'url'
-import { promisify } from 'util'
-import { notifySuccess, notifyError, notifyWarning, notifyInfo } from './notify.js'
-
-const execAsync = promisify(exec)
+import { spawn, exec, ChildProcess } from 'child_process';
+import {
+ readFileSync,
+ writeFileSync,
+ existsSync,
+ mkdirSync,
+ statSync,
+} from 'fs';
+import { networkInterfaces } from 'os';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { promisify } from 'util';
+import {
+ notifySuccess,
+ notifyError,
+ notifyWarning,
+ notifyInfo,
+} from './notify.js';
+
+const execAsync = promisify(exec);
// Get primary network IP address
function getNetworkIP(): string | null {
- const nets = networkInterfaces()
+ const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
// Skip internal (localhost) and non-IPv4 addresses
if (net.family === 'IPv4' && !net.internal) {
- return net.address
+ return net.address;
}
}
}
- return null
+ return null;
}
interface ServerConfig {
- command: string
- preferredPort: number
- healthCheck: string
+ command: string;
+ preferredPort: number;
+ healthCheck: string;
}
interface ServersConfig {
- [serverName: string]: ServerConfig
+ [serverName: string]: ServerConfig;
}
interface PidData {
[serverName: string]: {
- pid: number
- port: number
- startTime: string
- status: string
- }
+ pid: number;
+ port: number;
+ startTime: string;
+ status: string;
+ };
}
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-const projectRoot = path.resolve(__dirname, '..')
-const devDir = path.join(projectRoot, '.dev')
-const serversConfigPath = path.join(devDir, 'servers.json')
-const pidFilePath = path.join(devDir, 'pid.json')
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(__dirname, '..');
+const devDir = path.join(projectRoot, '.dev');
+const serversConfigPath = path.join(devDir, 'servers.json');
+const pidFilePath = path.join(devDir, 'pid.json');
// Ensure .dev directory exists
if (!existsSync(devDir)) {
- mkdirSync(devDir, { recursive: true })
+ mkdirSync(devDir, { recursive: true });
}
// Load configuration
function loadServersConfig(): ServersConfig {
if (!existsSync(serversConfigPath)) {
- console.error('Error: .dev/servers.json not found')
- console.error('Run: npx dev init')
- process.exit(1)
+ console.error('Error: .dev/servers.json not found');
+ console.error('Run: npx dev init');
+ process.exit(1);
}
try {
- return JSON.parse(readFileSync(serversConfigPath, 'utf8')) as ServersConfig
+ return JSON.parse(readFileSync(serversConfigPath, 'utf8')) as ServersConfig;
} catch (error) {
- console.error(`Error: Failed to parse ${serversConfigPath}`)
- console.error((error as Error).message)
- process.exit(1)
+ console.error(`Error: Failed to parse ${serversConfigPath}`);
+ console.error((error as Error).message);
+ process.exit(1);
}
}
// Load/save PID file
function loadPidFile(): PidData {
if (!existsSync(pidFilePath)) {
- return {}
+ return {};
}
try {
- return JSON.parse(readFileSync(pidFilePath, 'utf8')) as PidData
+ return JSON.parse(readFileSync(pidFilePath, 'utf8')) as PidData;
} catch (error) {
- console.warn(`Warning: Failed to parse ${pidFilePath}, using empty state`)
- console.warn((error as Error).message)
- return {}
+ console.warn(`Warning: Failed to parse ${pidFilePath}, using empty state`);
+ console.warn((error as Error).message);
+ return {};
}
}
function savePidFile(data: PidData): void {
- writeFileSync(pidFilePath, JSON.stringify(data, null, 2))
+ writeFileSync(pidFilePath, JSON.stringify(data, null, 2));
}
// Sanitize server name for use in filenames
function sanitizeServerName(serverName: string): string {
- return serverName.replace(/[^a-zA-Z0-9-_]/g, '-')
+ return serverName.replace(/[^a-zA-Z0-9-_]/g, '-');
}
// Get log file path for a server
function getLogFilePath(serverName: string): string {
- const sanitized = sanitizeServerName(serverName)
- return path.join(devDir, 'log', `${sanitized}.log`)
+ const sanitized = sanitizeServerName(serverName);
+ return path.join(devDir, 'log', `${sanitized}.log`);
}
// Check if process is running
async function isProcessRunning(pid: number): Promise {
try {
- await execAsync(`ps -p ${pid}`)
- return true
+ await execAsync(`ps -p ${pid}`);
+ return true;
} catch {
- return false
+ return false;
}
}
// Find free port starting from preferred port
async function findFreePort(startPort: number): Promise {
- let port = startPort
+ let port = startPort;
while (port < startPort + 100) {
try {
- await execAsync(`lsof -i :${port}`)
- port++
+ await execAsync(`lsof -i :${port}`);
+ port++;
} catch {
- return port // Port is free
+ return port; // Port is free
}
}
- throw new Error(`No free port found starting from ${startPort}`)
+ throw new Error(`No free port found starting from ${startPort}`);
}
// Health check with exponential backoff
-async function healthCheck(url: string, timeout = 10000, maxRetries = 3): Promise {
- const delays = [1000, 2000, 4000] // Exponential backoff: 1s, 2s, 4s
+async function healthCheck(
+ url: string,
+ timeout = 10000,
+ maxRetries = 3
+): Promise {
+ const delays = [1000, 2000, 4000]; // Exponential backoff: 1s, 2s, 4s
for (let attempt = 0; attempt < maxRetries; attempt++) {
const success = await new Promise(resolve => {
- const controller = new AbortController()
- const timeoutId = setTimeout(() => controller.abort(), timeout)
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
fetch(url, { signal: controller.signal })
.then(() => {
- clearTimeout(timeoutId)
- resolve(true)
+ clearTimeout(timeoutId);
+ resolve(true);
})
.catch(() => {
- clearTimeout(timeoutId)
- resolve(false)
- })
- })
+ clearTimeout(timeoutId);
+ resolve(false);
+ });
+ });
if (success) {
- return true
+ return true;
}
// Wait before retry (except on last attempt)
if (attempt < maxRetries - 1) {
- await new Promise(resolve => setTimeout(resolve, delays[attempt]))
+ await new Promise(resolve => setTimeout(resolve, delays[attempt]));
}
}
- return false
+ return false;
}
// Detect actual port from process output
function detectPortFromOutput(output: string): number | null {
- const match = output.match(/Local:\s+http:\/\/localhost:(\d+)/)
- return match ? parseInt(match[1]) : null
+ const match = output.match(/Local:\s+http:\/\/localhost:(\d+)/);
+ return match ? parseInt(match[1]) : null;
}
// Kill process using a specific port
async function killProcessOnPort(port: number) {
try {
- const { stdout } = await execAsync(`lsof -ti:${port}`)
+ const { stdout } = await execAsync(`lsof -ti:${port}`);
const pids = stdout
.trim()
.split('\n')
- .filter(p => p)
+ .filter(p => p);
if (pids.length > 0) {
- console.log(`🔧 Killing process(es) on port ${port}: ${pids.join(', ')}`)
+ console.log(`🔧 Killing process(es) on port ${port}: ${pids.join(', ')}`);
for (const pid of pids) {
try {
- process.kill(parseInt(pid), 'SIGTERM')
- await new Promise(resolve => setTimeout(resolve, 500)) // Wait for graceful shutdown
+ process.kill(parseInt(pid), 'SIGTERM');
+ await new Promise(resolve => setTimeout(resolve, 500)); // Wait for graceful shutdown
} catch (e) {
// Process might already be dead
}
}
// Wait a bit more for port to be released
- await new Promise(resolve => setTimeout(resolve, 1000))
+ await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (e) {
// No process on port, that's fine
@@ -189,65 +204,70 @@ async function killProcessOnPort(port: number) {
}
// Start server
-async function startServer(serverName: string, logViewerCmd: string | null = null) {
- const servers = loadServersConfig()
- const server = servers[serverName]
+async function startServer(
+ serverName: string,
+ logViewerCmd: string | null = null
+) {
+ const servers = loadServersConfig();
+ const server = servers[serverName];
if (!server) {
- console.error(`Error: Server '${serverName}' not found in .dev/servers.json`)
- process.exit(1)
+ console.error(
+ `Error: Server '${serverName}' not found in .dev/servers.json`
+ );
+ process.exit(1);
}
- const pidData = loadPidFile()
+ const pidData = loadPidFile();
// Check if already running
if (pidData[serverName]) {
- const isRunning = await isProcessRunning(pidData[serverName].pid)
+ const isRunning = await isProcessRunning(pidData[serverName].pid);
if (isRunning) {
console.log(
`${serverName} is already running on port ${pidData[serverName].port} (pid ${pidData[serverName].pid})`
- )
- console.log(`Use 'npx dev ${serverName} restart' to restart it`)
- return
+ );
+ console.log(`Use 'npx dev ${serverName} restart' to restart it`);
+ return;
} else {
// Clean up stale entry
- delete pidData[serverName]
- savePidFile(pidData)
+ delete pidData[serverName];
+ savePidFile(pidData);
}
}
try {
// For Vite, let it choose its own port, for others use our port management
- const preferredPort = server.preferredPort
- let actualPort: number, command: string
+ const preferredPort = server.preferredPort;
+ let actualPort: number, command: string;
if (server.command.includes('{PORT}')) {
// Port-managed server
- actualPort = await findFreePort(preferredPort)
- command = server.command.replace(/{PORT}/g, actualPort.toString())
+ actualPort = await findFreePort(preferredPort);
+ command = server.command.replace(/{PORT}/g, actualPort.toString());
} else {
// Let server choose (like Vite with config)
- actualPort = preferredPort
- command = server.command
+ actualPort = preferredPort;
+ command = server.command;
}
// Get log file path (automatically sanitized)
- const logFilePath = getLogFilePath(serverName)
+ const logFilePath = getLogFilePath(serverName);
// Ensure log directory exists
- const logDir = path.dirname(logFilePath)
+ const logDir = path.dirname(logFilePath);
if (!existsSync(logDir)) {
- mkdirSync(logDir, { recursive: true })
+ mkdirSync(logDir, { recursive: true });
}
// Add log redirection if not already present
if (!command.includes('>')) {
- command = `${command} > ${logFilePath} 2>&1`
+ command = `${command} > ${logFilePath} 2>&1`;
}
- console.log(`Starting ${serverName}...`)
+ console.log(`Starting ${serverName}...`);
if (actualPort !== preferredPort) {
- console.log(`Port :${preferredPort} busy, using :${actualPort}`)
+ console.log(`Port :${preferredPort} busy, using :${actualPort}`);
}
// Start process using shell to handle redirection
@@ -255,37 +275,41 @@ async function startServer(serverName: string, logViewerCmd: string | null = nul
detached: true,
stdio: 'ignore',
cwd: projectRoot,
- })
+ });
// Verify spawn succeeded
if (child.pid === undefined) {
- console.error(`❌ Failed to start ${serverName}: spawn returned no PID`)
- notifyError(serverName, 'Failed to spawn process')
- process.exit(1)
+ console.error(`❌ Failed to start ${serverName}: spawn returned no PID`);
+ notifyError(serverName, 'Failed to spawn process');
+ process.exit(1);
}
- child.unref()
+ child.unref();
// Wait for startup
- await new Promise(resolve => setTimeout(resolve, 3000))
+ await new Promise(resolve => setTimeout(resolve, 3000));
// Try to detect actual port from logs (for servers that auto-select ports like Next.js)
try {
// First try lsof with the PID
- const { stdout } = await execAsync(`lsof -i -P -n | grep ${child.pid} | grep LISTEN`)
- const portMatch = stdout.match(/:(\d+)\s+\(LISTEN\)/)
+ const { stdout } = await execAsync(
+ `lsof -i -P -n | grep ${child.pid} | grep LISTEN`
+ );
+ const portMatch = stdout.match(/:(\d+)\s+\(LISTEN\)/);
if (portMatch) {
- actualPort = parseInt(portMatch[1])
+ actualPort = parseInt(portMatch[1]);
}
} catch (e) {
// lsof failed, try parsing log file for Next.js port
try {
if (existsSync(logFilePath)) {
- const logContent = readFileSync(logFilePath, 'utf-8')
+ const logContent = readFileSync(logFilePath, 'utf-8');
// Match Next.js log format: "- Local: http://localhost:4323"
- const nextPortMatch = logContent.match(/Local:\s+http:\/\/localhost:(\d+)/)
+ const nextPortMatch = logContent.match(
+ /Local:\s+http:\/\/localhost:(\d+)/
+ );
if (nextPortMatch) {
- actualPort = parseInt(nextPortMatch[1])
+ actualPort = parseInt(nextPortMatch[1]);
}
}
} catch (logErr) {
@@ -296,9 +320,9 @@ async function startServer(serverName: string, logViewerCmd: string | null = nul
// Health check
const healthUrl = server.healthCheck
.replace('{PORT}', String(actualPort))
- .replace(/{ROLE}/g, serverName)
+ .replace(/{ROLE}/g, serverName);
- const isHealthy = await healthCheck(healthUrl)
+ const isHealthy = await healthCheck(healthUrl);
if (isHealthy) {
// Save to PID file
@@ -307,138 +331,156 @@ async function startServer(serverName: string, logViewerCmd: string | null = nul
port: actualPort,
startTime: new Date().toISOString(),
status: 'healthy',
- }
- savePidFile(pidData)
+ };
+ savePidFile(pidData);
const portInfo =
- actualPort === preferredPort ? `:${actualPort}` : `:${preferredPort}→${actualPort}`
- console.log(`✅ ${serverName} ${portInfo} (pid ${child.pid})`)
+ actualPort === preferredPort
+ ? `:${actualPort}`
+ : `:${preferredPort}→${actualPort}`;
+ console.log(`✅ ${serverName} ${portInfo} (pid ${child.pid})`);
// Notify success
- notifySuccess(serverName, `Started on ${portInfo}`)
+ notifySuccess(serverName, `Started on ${portInfo}`);
// Start log viewer if configured
if (logViewerCmd) {
- const logFile = getLogFilePath(serverName)
- console.log(`\nStarting log viewer: ${logViewerCmd} ${logFile}`)
- console.log('Press Ctrl+C to stop following logs\n')
+ const logFile = getLogFilePath(serverName);
+ console.log(`\nStarting log viewer: ${logViewerCmd} ${logFile}`);
+ console.log('Press Ctrl+C to stop following logs\n');
// Check if log file exists
if (!existsSync(logFile)) {
- console.log(`Warning: Log file ${logFile} not found. Creating empty file...`)
- writeFileSync(logFile, '')
+ console.log(
+ `Warning: Log file ${logFile} not found. Creating empty file...`
+ );
+ writeFileSync(logFile, '');
}
// Parse and execute log viewer command
- const logViewerArgs = logViewerCmd.split(' ')
- const logViewerProcess = spawn(logViewerArgs[0], [...logViewerArgs.slice(1), logFile], {
- stdio: 'inherit',
- })
+ const logViewerArgs = logViewerCmd.split(' ');
+ const logViewerProcess = spawn(
+ logViewerArgs[0],
+ [...logViewerArgs.slice(1), logFile],
+ {
+ stdio: 'inherit',
+ }
+ );
// Handle Ctrl+C
process.on('SIGINT', () => {
- console.log('\n\nStopped following logs')
- logViewerProcess.kill()
- process.exit(0)
- })
+ console.log('\n\nStopped following logs');
+ logViewerProcess.kill();
+ process.exit(0);
+ });
// Monitor if the server process is still running
const monitorInterval = setInterval(async () => {
- const stillRunning = await isProcessRunning(child.pid!)
+ const stillRunning = await isProcessRunning(child.pid!);
if (!stillRunning) {
- console.log(`\n❌ Server ${serverName} (pid ${child.pid}) has stopped`)
- notifyError(serverName, 'Stopped unexpectedly')
- clearInterval(monitorInterval)
- logViewerProcess.kill()
+ console.log(
+ `\n❌ Server ${serverName} (pid ${child.pid}) has stopped`
+ );
+ notifyError(serverName, 'Stopped unexpectedly');
+ clearInterval(monitorInterval);
+ logViewerProcess.kill();
// Clean up PID file
- const currentPidData = loadPidFile()
- delete currentPidData[serverName]
- savePidFile(currentPidData)
- process.exit(0)
+ const currentPidData = loadPidFile();
+ delete currentPidData[serverName];
+ savePidFile(currentPidData);
+ process.exit(0);
}
- }, 5000)
+ }, 5000);
}
} else {
// Kill the process and report failure
try {
- process.kill(child.pid, 'SIGTERM')
+ process.kill(child.pid, 'SIGTERM');
} catch (e) {
// Process might already be dead
}
- console.error(`❌ ${serverName} failed health check at ${healthUrl}`)
- console.error(`Check logs: npx dev logs ${serverName}`)
- notifyError(serverName, 'Failed health check')
- process.exit(1)
+ console.error(`❌ ${serverName} failed health check at ${healthUrl}`);
+ console.error(`Check logs: npx dev logs ${serverName}`);
+ notifyError(serverName, 'Failed health check');
+ process.exit(1);
}
} catch (error: any) {
- console.error(`Error starting ${serverName}:`, error.message)
- notifyError(serverName, `Failed to start: ${error.message}`)
- process.exit(1)
+ console.error(`Error starting ${serverName}:`, error.message);
+ notifyError(serverName, `Failed to start: ${error.message}`);
+ process.exit(1);
}
}
// Stop servers
async function stopServers(serverName: string | null = null) {
- const pidData = loadPidFile()
- const servers = loadServersConfig()
+ const pidData = loadPidFile();
+ const servers = loadServersConfig();
// If specific server requested, only stop that one
// Otherwise stop all servers in PID file
- const serversToStop = serverName ? [serverName] : Object.keys(pidData)
+ const serversToStop = serverName ? [serverName] : Object.keys(pidData);
for (const name of serversToStop) {
- const serverConfig = servers[name]
+ const serverConfig = servers[name];
// If no PID data but server config exists, try to kill by preferred port
if (!pidData[name] && serverConfig) {
- console.log(`${name} not in PID file, checking ports...`)
+ console.log(`${name} not in PID file, checking ports...`);
// Try to detect actual port from logs first
- let actualPort: number | null = null
- const logFilePath = getLogFilePath(name)
+ let actualPort: number | null = null;
+ const logFilePath = getLogFilePath(name);
if (existsSync(logFilePath)) {
- const logContent = readFileSync(logFilePath, 'utf-8')
- const nextPortMatch = logContent.match(/Local:\s+http:\/\/localhost:(\d+)/)
+ const logContent = readFileSync(logFilePath, 'utf-8');
+ const nextPortMatch = logContent.match(
+ /Local:\s+http:\/\/localhost:(\d+)/
+ );
if (nextPortMatch) {
- actualPort = parseInt(nextPortMatch[1])
+ actualPort = parseInt(nextPortMatch[1]);
}
}
// Collect all ports to check: actual port (if detected) + preferred port
- const portsToCheck = new Set()
+ const portsToCheck = new Set();
if (actualPort) {
- portsToCheck.add(actualPort)
+ portsToCheck.add(actualPort);
}
- portsToCheck.add(serverConfig.preferredPort)
+ portsToCheck.add(serverConfig.preferredPort);
- let foundProcesses = false
+ let foundProcesses = false;
for (const port of portsToCheck) {
- const portPids = await getPidsByPort(port)
+ const portPids = await getPidsByPort(port);
if (portPids.length > 0) {
- foundProcesses = true
+ foundProcesses = true;
const portLabel =
- port === serverConfig.preferredPort ? `${port} (preferred)` : `${port} (actual)`
- console.log(`🛑 Stopping ${name} on port ${portLabel}...`)
- console.log(`🔧 Found ${portPids.length} process(es): ${portPids.join(', ')}`)
+ port === serverConfig.preferredPort
+ ? `${port} (preferred)`
+ : `${port} (actual)`;
+ console.log(`🛑 Stopping ${name} on port ${portLabel}...`);
+ console.log(
+ `🔧 Found ${portPids.length} process(es): ${portPids.join(', ')}`
+ );
for (const portPid of portPids) {
try {
- await killProcessTree(portPid, 'SIGTERM')
+ await killProcessTree(portPid, 'SIGTERM');
} catch (e) {
// Process might already be dead
}
}
// Wait for graceful shutdown
- await new Promise(resolve => setTimeout(resolve, 2000))
+ await new Promise(resolve => setTimeout(resolve, 2000));
// Force kill if still there
- const remainingPids = await getPidsByPort(port)
+ const remainingPids = await getPidsByPort(port);
if (remainingPids.length > 0) {
- console.log(`⚠️ Forcing SIGKILL on remaining processes on port ${port}...`)
+ console.log(
+ `⚠️ Forcing SIGKILL on remaining processes on port ${port}...`
+ );
for (const portPid of remainingPids) {
try {
- await killProcessTree(portPid, 'SIGKILL')
+ await killProcessTree(portPid, 'SIGKILL');
} catch (e) {
// Process might already be dead
}
@@ -448,36 +490,38 @@ async function stopServers(serverName: string | null = null) {
}
if (!foundProcesses) {
- console.log(`${name} is not running`)
- continue
+ console.log(`${name} is not running`);
+ continue;
}
- console.log(`✅ ${name} stopped`)
- continue
+ console.log(`✅ ${name} stopped`);
+ continue;
}
if (!pidData[name]) {
if (serverName) {
- console.log(`${name} is not running`)
+ console.log(`${name} is not running`);
}
- continue
+ continue;
}
- const { pid, port } = pidData[name]
- const preferredPort = servers[name]?.preferredPort
- const isRunning = await isProcessRunning(pid)
+ const { pid, port } = pidData[name];
+ const preferredPort = servers[name]?.preferredPort;
+ const isRunning = await isProcessRunning(pid);
// Always try to kill by port first (handles orphaned processes)
// This ensures we clean up even if the wrapper PID is dead
- console.log(`🛑 Stopping ${name} (pid ${pid}, port ${port})...`)
+ console.log(`🛑 Stopping ${name} (pid ${pid}, port ${port})...`);
// First, kill by port to catch any orphaned processes
- const portPids = await getPidsByPort(port)
+ const portPids = await getPidsByPort(port);
if (portPids.length > 0) {
- console.log(`🔧 Found ${portPids.length} process(es) on port ${port}: ${portPids.join(', ')}`)
+ console.log(
+ `🔧 Found ${portPids.length} process(es) on port ${port}: ${portPids.join(', ')}`
+ );
for (const portPid of portPids) {
try {
- await killProcessTree(portPid, 'SIGTERM')
+ await killProcessTree(portPid, 'SIGTERM');
} catch (e) {
// Process might already be dead
}
@@ -488,202 +532,217 @@ async function stopServers(serverName: string | null = null) {
if (isRunning) {
try {
// Kill entire process tree (parent + all children)
- await killProcessTree(pid, 'SIGTERM')
+ await killProcessTree(pid, 'SIGTERM');
// Wait for graceful shutdown (up to 3 seconds)
- let attempts = 0
- const maxAttempts = 6 // 6 attempts * 500ms = 3 seconds
+ let attempts = 0;
+ const maxAttempts = 6; // 6 attempts * 500ms = 3 seconds
while (attempts < maxAttempts) {
- await new Promise(resolve => setTimeout(resolve, 500))
- const stillRunning = await isProcessRunning(pid)
+ await new Promise(resolve => setTimeout(resolve, 500));
+ const stillRunning = await isProcessRunning(pid);
if (!stillRunning) {
- console.log(`✅ ${name} stopped gracefully`)
- notifyInfo(name, 'Stopped')
- break
+ console.log(`✅ ${name} stopped gracefully`);
+ notifyInfo(name, 'Stopped');
+ break;
}
- attempts++
+ attempts++;
}
// If still running after graceful shutdown period, force kill entire tree
if (await isProcessRunning(pid)) {
- console.log(`⚠️ ${name} didn't stop gracefully, forcing SIGKILL...`)
- await killProcessTree(pid, 'SIGKILL')
+ console.log(`⚠️ ${name} didn't stop gracefully, forcing SIGKILL...`);
+ await killProcessTree(pid, 'SIGKILL');
// Wait for SIGKILL to take effect - don't give up
- let killAttempts = 0
+ let killAttempts = 0;
while (await isProcessRunning(pid)) {
- await new Promise(resolve => setTimeout(resolve, 200))
- killAttempts++
+ await new Promise(resolve => setTimeout(resolve, 200));
+ killAttempts++;
if (killAttempts > 10) {
// After 2 seconds of SIGKILL not working, something is very wrong
- throw new Error(`Process ${pid} won't die even with SIGKILL`)
+ throw new Error(`Process ${pid} won't die even with SIGKILL`);
}
}
- console.log(`✅ ${name} force stopped`)
- notifyInfo(name, 'Force stopped')
+ console.log(`✅ ${name} force stopped`);
+ notifyInfo(name, 'Force stopped');
}
} catch (error: any) {
- console.error(`Error stopping ${name}:`, error.message)
- notifyError(name, `Error stopping: ${error.message}`)
+ console.error(`Error stopping ${name}:`, error.message);
+ notifyError(name, `Error stopping: ${error.message}`);
}
} else {
- console.log(`${name} process not running, cleaning up ports...`)
+ console.log(`${name} process not running, cleaning up ports...`);
}
// Reclaim ports: both the actual port AND the preferred port
- const portsToReclaim = [port]
+ const portsToReclaim = [port];
if (preferredPort && preferredPort !== port) {
- portsToReclaim.push(preferredPort)
+ portsToReclaim.push(preferredPort);
}
for (const portToReclaim of portsToReclaim) {
const portLabel =
portToReclaim === preferredPort && portToReclaim !== port
? `${portToReclaim} (preferred)`
- : `${portToReclaim}`
+ : `${portToReclaim}`;
- console.log(`🔓 Reclaiming port ${portLabel}...`)
- let portAttempts = 0
+ console.log(`🔓 Reclaiming port ${portLabel}...`);
+ let portAttempts = 0;
while (true) {
try {
- const { stdout } = await execAsync(`lsof -ti:${portToReclaim}`)
+ const { stdout } = await execAsync(`lsof -ti:${portToReclaim}`);
const pids = stdout
.trim()
.split('\n')
- .filter(p => p)
+ .filter(p => p);
if (pids.length === 0) {
// Port is free!
- console.log(`✅ Port ${portLabel} is free`)
- break
+ console.log(`✅ Port ${portLabel} is free`);
+ break;
}
// Kill everything on this port
console.log(
`🔧 Killing ${pids.length} process(es) on port ${portLabel}: ${pids.join(', ')}`
- )
+ );
for (const portPid of pids) {
try {
// Start with SIGTERM
- process.kill(parseInt(portPid), 'SIGTERM')
+ process.kill(parseInt(portPid), 'SIGTERM');
} catch (e) {
// Process might already be dead
}
}
// Wait a bit for graceful shutdown
- await new Promise(resolve => setTimeout(resolve, 1000))
+ await new Promise(resolve => setTimeout(resolve, 1000));
// Check again and use SIGKILL if needed
try {
- const { stdout: remainingPids } = await execAsync(`lsof -ti:${portToReclaim}`)
+ const { stdout: remainingPids } = await execAsync(
+ `lsof -ti:${portToReclaim}`
+ );
const remaining = remainingPids
.trim()
.split('\n')
- .filter(p => p)
+ .filter(p => p);
if (remaining.length > 0) {
- console.log(`⚠️ Port ${portLabel} still held, using SIGKILL...`)
+ console.log(`⚠️ Port ${portLabel} still held, using SIGKILL...`);
for (const portPid of remaining) {
try {
- process.kill(parseInt(portPid), 'SIGKILL')
+ process.kill(parseInt(portPid), 'SIGKILL');
} catch (e) {
// Process might already be dead
}
}
- await new Promise(resolve => setTimeout(resolve, 500))
+ await new Promise(resolve => setTimeout(resolve, 500));
}
} catch (e) {
// No processes found, port is free
- break
+ break;
}
- portAttempts++
+ portAttempts++;
if (portAttempts > 20) {
- console.error(`❌ Failed to reclaim port ${portLabel} after 20 attempts`)
- notifyError(name, `Could not reclaim port ${portLabel}`)
- throw new Error(`Could not reclaim port ${portToReclaim}`)
+ console.error(
+ `❌ Failed to reclaim port ${portLabel} after 20 attempts`
+ );
+ notifyError(name, `Could not reclaim port ${portLabel}`);
+ throw new Error(`Could not reclaim port ${portToReclaim}`);
}
} catch (error: any) {
if (error.message && error.message.includes('Could not reclaim')) {
- throw error
+ throw error;
}
// lsof error means no process on port - we're done!
- console.log(`✅ Port ${portLabel} is free`)
- break
+ console.log(`✅ Port ${portLabel} is free`);
+ break;
}
}
}
- delete pidData[name]
+ delete pidData[name];
}
- savePidFile(pidData)
+ savePidFile(pidData);
}
// Show status
async function showStatus() {
- const pidData = loadPidFile()
- const servers = loadServersConfig()
+ const pidData = loadPidFile();
+ const servers = loadServersConfig();
if (Object.keys(pidData).length === 0) {
- console.log('No servers running')
- return
+ console.log('No servers running');
+ return;
}
- console.log('Running servers:')
+ console.log('Running servers:');
for (const [name, data] of Object.entries(pidData)) {
- const isRunning = await isProcessRunning(data.pid)
+ const isRunning = await isProcessRunning(data.pid);
if (isRunning) {
- console.log(` ${name}: port ${data.port} (pid ${data.pid}) - ${data.status}`)
+ console.log(
+ ` ${name}: port ${data.port} (pid ${data.pid}) - ${data.status}`
+ );
} else {
- console.log(` ${name}: DEAD (pid ${data.pid}) - cleaning up`)
- delete pidData[name]
+ console.log(` ${name}: DEAD (pid ${data.pid}) - cleaning up`);
+ delete pidData[name];
}
}
- savePidFile(pidData)
+ savePidFile(pidData);
}
// List servers (status)
async function listServers() {
- const pidData = loadPidFile()
- const servers = loadServersConfig()
+ const pidData = loadPidFile();
+ const servers = loadServersConfig();
if (Object.keys(pidData).length === 0) {
- console.log('No servers running')
- return
+ console.log('No servers running');
+ return;
}
// Build table rows
- const rows: Array<{status: string, server: string, url: string, pid: number | string, port: string}> = []
+ const rows: Array<{
+ status: string;
+ server: string;
+ url: string;
+ pid: number | string;
+ port: string;
+ }> = [];
for (const [name, data] of Object.entries(pidData)) {
- const isRunning = await isProcessRunning(data.pid)
- const server = servers[name]
+ const isRunning = await isProcessRunning(data.pid);
+ const server = servers[name];
// Health check
- let statusText = 'dead'
- let statusIcon = '[X]' // dead
+ let statusText = 'dead';
+ let statusIcon = '[X]'; // dead
if (isRunning && server) {
- const healthUrl = server.healthCheck.replace('{PORT}', data.port.toString())
- const isHealthy = await healthCheck(healthUrl, 2000)
+ const healthUrl = server.healthCheck.replace(
+ '{PORT}',
+ data.port.toString()
+ );
+ const isHealthy = await healthCheck(healthUrl, 2000);
if (isHealthy) {
- statusText = 'up'
- statusIcon = '[*]' // healthy
+ statusText = 'up';
+ statusIcon = '[*]'; // healthy
} else {
- statusText = 'unhealthy'
- statusIcon = '[!]' // unhealthy
+ statusText = 'unhealthy';
+ statusIcon = '[!]'; // unhealthy
}
} else if (isRunning) {
- statusText = 'up'
- statusIcon = '[*]' // healthy
+ statusText = 'up';
+ statusIcon = '[*]'; // healthy
}
// Port display
const portInfo =
server && data.port !== server.preferredPort
? `:${server.preferredPort}→${data.port}`
- : `:${data.port}`
+ : `:${data.port}`;
// Main row
rows.push({
@@ -692,10 +751,10 @@ async function listServers() {
url: `http://localhost:${data.port}`,
pid: data.pid,
port: portInfo,
- })
+ });
// Network IP sub-row
- const networkIp = getNetworkIP()
+ const networkIp = getNetworkIP();
if (networkIp && networkIp !== '127.0.0.1') {
rows.push({
status: '',
@@ -703,11 +762,11 @@ async function listServers() {
url: `http://${networkIp}:${data.port}`,
pid: '',
port: '',
- })
+ });
}
if (!isRunning) {
- delete pidData[name]
+ delete pidData[name];
}
}
@@ -718,7 +777,7 @@ async function listServers() {
url: 30,
pid: 6,
port: 10,
- }
+ };
// Print header
console.log(
@@ -729,156 +788,167 @@ async function listServers() {
'PID'.padEnd(widths.pid),
'PORT',
].join(' ')
- )
+ );
- console.log('─'.repeat(widths.status + widths.server + widths.url + widths.pid + widths.port + 8))
+ console.log(
+ '─'.repeat(
+ widths.status + widths.server + widths.url + widths.pid + widths.port + 8
+ )
+ );
// Print rows
for (const row of rows) {
- const statusStr = row.status.padEnd(widths.status)
- const serverStr = row.server.padEnd(widths.server)
- const urlStr = row.url.padEnd(widths.url)
- const pidStr = String(row.pid).padEnd(widths.pid)
- const portStr = row.port
+ const statusStr = row.status.padEnd(widths.status);
+ const serverStr = row.server.padEnd(widths.server);
+ const urlStr = row.url.padEnd(widths.url);
+ const pidStr = String(row.pid).padEnd(widths.pid);
+ const portStr = row.port;
- console.log([statusStr, serverStr, urlStr, pidStr, portStr].join(' '))
+ console.log([statusStr, serverStr, urlStr, pidStr, portStr].join(' '));
}
- savePidFile(pidData)
+ savePidFile(pidData);
}
// Show logs for a server
async function showLogs(serverName: string | null) {
- const pidData = loadPidFile()
+ const pidData = loadPidFile();
if (!serverName) {
- const runningServers = Object.keys(pidData)
+ const runningServers = Object.keys(pidData);
if (runningServers.length === 0) {
- console.log('No servers running')
- return
+ console.log('No servers running');
+ return;
}
if (runningServers.length === 1) {
- serverName = runningServers[0]
+ serverName = runningServers[0];
} else {
- console.log('Multiple servers running. Specify which one:')
- runningServers.forEach(name => console.log(` npx dev logs ${name}`))
- return
+ console.log('Multiple servers running. Specify which one:');
+ runningServers.forEach(name => console.log(` npx dev logs ${name}`));
+ return;
}
}
if (!pidData[serverName]) {
- console.log(`Server '${serverName}' is not running`)
- return
+ console.log(`Server '${serverName}' is not running`);
+ return;
}
- const { pid, port } = pidData[serverName]
- const logFile = getLogFilePath(serverName)
+ const { pid, port } = pidData[serverName];
+ const logFile = getLogFilePath(serverName);
- console.log(`Following logs for ${serverName} (pid ${pid}, port ${port})`)
- console.log(`Log file: ${logFile}`)
- console.log('Press Ctrl+C to stop following logs\n')
+ console.log(`Following logs for ${serverName} (pid ${pid}, port ${port})`);
+ console.log(`Log file: ${logFile}`);
+ console.log('Press Ctrl+C to stop following logs\n');
// Check if log file exists
if (!existsSync(logFile)) {
- console.log(`Log file not found. Server may have been started before logging was implemented.`)
- console.log(`Restart the server to enable logging: npm run stop && npm run dev\n`)
- return
+ console.log(
+ `Log file not found. Server may have been started before logging was implemented.`
+ );
+ console.log(
+ `Restart the server to enable logging: npm run stop && npm run dev\n`
+ );
+ return;
}
// Use tail -f to follow the log file
const tailProcess = spawn('tail', ['-f', logFile], {
stdio: 'inherit',
- })
+ });
// Handle Ctrl+C
process.on('SIGINT', () => {
- console.log('\n\nStopped following logs')
- tailProcess.kill()
- process.exit(0)
- })
+ console.log('\n\nStopped following logs');
+ tailProcess.kill();
+ process.exit(0);
+ });
// Monitor if the server process is still running
const monitorInterval = setInterval(async () => {
- const stillRunning = await isProcessRunning(pid)
+ const stillRunning = await isProcessRunning(pid);
if (!stillRunning) {
- console.log(`\n❌ Server ${serverName} (pid ${pid}) has stopped`)
- notifyError(serverName!, 'Stopped unexpectedly')
- clearInterval(monitorInterval)
- tailProcess.kill()
+ console.log(`\n❌ Server ${serverName} (pid ${pid}) has stopped`);
+ notifyError(serverName!, 'Stopped unexpectedly');
+ clearInterval(monitorInterval);
+ tailProcess.kill();
// Clean up PID file
- const currentPidData = loadPidFile()
- delete currentPidData[serverName!]
- savePidFile(currentPidData)
- process.exit(0)
+ const currentPidData = loadPidFile();
+ delete currentPidData[serverName!];
+ savePidFile(currentPidData);
+ process.exit(0);
}
- }, 5000)
+ }, 5000);
}
// Cleanup stale entries
async function cleanup() {
- const pidData = loadPidFile()
- const cleanedData: PidData = {}
+ const pidData = loadPidFile();
+ const cleanedData: PidData = {};
for (const [name, data] of Object.entries(pidData)) {
- const isRunning = await isProcessRunning(data.pid)
+ const isRunning = await isProcessRunning(data.pid);
if (isRunning) {
- cleanedData[name] = data
+ cleanedData[name] = data;
} else {
- console.log(`Cleaned up stale entry: ${name} (pid ${data.pid})`)
+ console.log(`Cleaned up stale entry: ${name} (pid ${data.pid})`);
}
}
- savePidFile(cleanedData)
+ savePidFile(cleanedData);
}
// Get all descendant PIDs of a process (children, grandchildren, etc.)
async function getAllDescendantPids(parentPid: number): Promise {
try {
// Use pgrep to find all descendants recursively
- const { stdout } = await execAsync(`pgrep -P ${parentPid}`)
+ const { stdout } = await execAsync(`pgrep -P ${parentPid}`);
const childPids = stdout
.trim()
.split('\n')
.map(pid => parseInt(pid))
- .filter(pid => !isNaN(pid))
+ .filter(pid => !isNaN(pid));
// Recursively get descendants of each child
- const allDescendants = [...childPids]
+ const allDescendants = [...childPids];
for (const childPid of childPids) {
- const grandchildren = await getAllDescendantPids(childPid)
- allDescendants.push(...grandchildren)
+ const grandchildren = await getAllDescendantPids(childPid);
+ allDescendants.push(...grandchildren);
}
- return allDescendants
+ return allDescendants;
} catch {
- return []
+ return [];
}
}
// Find all PIDs listening on a specific port
async function getPidsByPort(port: number): Promise {
try {
- const { stdout } = await execAsync(`lsof -ti :${port}`)
+ const { stdout } = await execAsync(`lsof -ti :${port}`);
return stdout
.trim()
.split('\n')
.map(pid => parseInt(pid))
- .filter(pid => !isNaN(pid))
+ .filter(pid => !isNaN(pid));
} catch {
- return []
+ return [];
}
}
// Kill entire process tree (parent + all descendants)
-async function killProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'): Promise {
+async function killProcessTree(
+ pid: number,
+ signal: NodeJS.Signals = 'SIGTERM'
+): Promise {
try {
// Get all descendant PIDs first
- const descendants = await getAllDescendantPids(pid)
+ const descendants = await getAllDescendantPids(pid);
// Kill descendants first (bottom-up)
for (const descendantPid of descendants.reverse()) {
try {
- process.kill(descendantPid, signal)
+ process.kill(descendantPid, signal);
} catch {
// Process might already be dead
}
@@ -886,7 +956,7 @@ async function killProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'):
// Finally kill the parent
try {
- process.kill(pid, signal)
+ process.kill(pid, signal);
} catch {
// Process might already be dead
}
@@ -897,26 +967,26 @@ async function killProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'):
// Doctor command - show status + configuration
async function doctor() {
- const servers = loadServersConfig()
- const serversConfigExists = existsSync(serversConfigPath)
+ const servers = loadServersConfig();
+ const serversConfigExists = existsSync(serversConfigPath);
if (!serversConfigExists) {
- console.log('ERROR: .dev/servers.json missing')
- console.log(' → npx dev init\n')
- return
+ console.log('ERROR: .dev/servers.json missing');
+ console.log(' → npx dev init\n');
+ return;
}
// Show status first
- await listServers()
+ await listServers();
// Then show configuration
- console.log(`\nConfiguration:`)
- console.log(` Path: ${serversConfigPath}`)
- console.log(` Content:`)
- const configContent = JSON.stringify(servers, null, 2)
+ console.log(`\nConfiguration:`);
+ console.log(` Path: ${serversConfigPath}`);
+ console.log(` Content:`);
+ const configContent = JSON.stringify(servers, null, 2);
configContent.split('\n').forEach(line => {
- console.log(` ${line}`)
- })
+ console.log(` ${line}`);
+ });
}
// Initialize dev environment
@@ -925,30 +995,30 @@ function initializeDevEnvironment() {
if (existsSync(serversConfigPath)) {
console.error(
'Error: .dev/servers.json already exists. Remove it first if you want to reinitialize.'
- )
- process.exit(1)
+ );
+ process.exit(1);
}
// Ensure directories exist
- const logDir = path.join(devDir, 'log')
+ const logDir = path.join(devDir, 'log');
if (!existsSync(logDir)) {
- mkdirSync(logDir, { recursive: true })
+ mkdirSync(logDir, { recursive: true });
}
// Read package.json to infer servers
- const packageJsonPath = path.join(projectRoot, 'package.json')
+ const packageJsonPath = path.join(projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) {
- console.error('Error: package.json not found in project root')
- process.exit(1)
+ console.error('Error: package.json not found in project root');
+ process.exit(1);
}
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
- const scripts = packageJson.scripts || {}
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
+ const scripts = packageJson.scripts || {};
// Define patterns to detect server scripts
- const serverPatterns = ['dev', 'start', 'serve', 'preview']
- const inferredServers: ServersConfig = {}
- let portCounter = 3000
+ const serverPatterns = ['dev', 'start', 'serve', 'preview'];
+ const inferredServers: ServersConfig = {};
+ let portCounter = 3000;
for (const [scriptName, scriptCommand] of Object.entries(scripts)) {
// Check if script name matches server patterns
@@ -958,108 +1028,120 @@ function initializeDevEnvironment() {
!scriptName.includes('build') &&
!scriptName.includes('test') &&
!scriptName.includes('lint')
- )
+ );
if (isServerScript && !(scriptCommand as string).includes('npx dev')) {
// Generate server configuration
- const serverName = scriptName.replace(/^(npm run |yarn |pnpm )?/, '')
- const logPath = `.dev/log/${serverName}.log`
+ const serverName = scriptName.replace(/^(npm run |yarn |pnpm )?/, '');
+ const logPath = `.dev/log/${serverName}.log`;
inferredServers[serverName] = {
command: `npm run ${scriptName} > ${logPath} 2>&1`,
preferredPort: portCounter,
healthCheck: `http://localhost:{PORT}`,
- }
+ };
- portCounter += 10 // Space out ports
+ portCounter += 10; // Space out ports
}
}
if (Object.keys(inferredServers).length === 0) {
- console.log('No server scripts detected in package.json')
- console.log('Creating minimal configuration...')
+ console.log('No server scripts detected in package.json');
+ console.log('Creating minimal configuration...');
// Create minimal default configuration
inferredServers.dev = {
command: 'npm run dev > .dev/log/dev.log 2>&1',
preferredPort: 3000,
healthCheck: 'http://localhost:{PORT}',
- }
+ };
}
// Write servers.json
- writeFileSync(serversConfigPath, JSON.stringify(inferredServers, null, 2))
- console.log(`✅ Created .dev/servers.json with ${Object.keys(inferredServers).length} server(s):`)
+ writeFileSync(serversConfigPath, JSON.stringify(inferredServers, null, 2));
+ console.log(
+ `✅ Created .dev/servers.json with ${Object.keys(inferredServers).length} server(s):`
+ );
for (const serverName of Object.keys(inferredServers)) {
- console.log(` - ${serverName}`)
+ console.log(` - ${serverName}`);
}
// Create empty pid.json
- writeFileSync(pidFilePath, JSON.stringify({}, null, 2))
- console.log('✅ Created .dev/pid.json')
- console.log('✅ Created .dev/log/ directory')
-
- console.log('\nYou can now run:')
- console.log(' npx dev start # Start first server')
- console.log(' npx dev start # Start specific server')
- console.log(' npx dev status # Check running servers')
+ writeFileSync(pidFilePath, JSON.stringify({}, null, 2));
+ console.log('✅ Created .dev/pid.json');
+ console.log('✅ Created .dev/log/ directory');
+
+ console.log('\nYou can now run:');
+ console.log(' npx dev start # Start first server');
+ console.log(' npx dev start # Start specific server');
+ console.log(' npx dev status # Check running servers');
}
// Known commands (command-first pattern)
-const COMMANDS = ['start', 'stop', 'restart', 'status', 'logs', 'doctor', 'cleanup', 'init', 'help']
+const COMMANDS = [
+ 'start',
+ 'stop',
+ 'restart',
+ 'status',
+ 'logs',
+ 'doctor',
+ 'cleanup',
+ 'init',
+ 'help',
+];
// Parse CLI arguments
function parseArguments() {
- const args = process.argv.slice(2)
+ const args = process.argv.slice(2);
const parsed: {
- command: string | null
- serverName: string | null
- logViewer: string | null
+ command: string | null;
+ serverName: string | null;
+ logViewer: string | null;
} = {
command: null,
serverName: null,
logViewer: null,
- }
+ };
for (let i = 0; i < args.length; i++) {
- const arg = args[i]
+ const arg = args[i];
if (arg === '--log-viewer' && i + 1 < args.length) {
- parsed.logViewer = args[i + 1]
- i++ // Skip next argument as it's the value
+ parsed.logViewer = args[i + 1];
+ i++; // Skip next argument as it's the value
} else if (!parsed.command) {
- parsed.command = arg
+ parsed.command = arg;
} else if (!parsed.serverName) {
- parsed.serverName = arg
+ parsed.serverName = arg;
}
}
- return parsed
+ return parsed;
}
// Get log viewer command with priority: CLI args > env vars > default
function getLogViewerCommand(cliLogViewer: string | null): string {
- return cliLogViewer || process.env.DEV_LOG_VIEWER || 'tail -f'
+ return cliLogViewer || process.env.DEV_LOG_VIEWER || 'tail -f';
}
// Show help
function showHelp() {
- const pidData = loadPidFile()
- const servers = loadServersConfig()
- const running = Object.keys(pidData).length
+ const pidData = loadPidFile();
+ const servers = loadServersConfig();
+ const running = Object.keys(pidData).length;
if (running > 0) {
- console.log('Running:')
+ console.log('Running:');
for (const [name, data] of Object.entries(pidData)) {
- const server = servers[name]
+ const server = servers[name];
const portInfo =
server && data.port !== server.preferredPort
? `:${server.preferredPort}→${data.port}`
- : `:${data.port}`
- console.log(` ${name} ${portInfo}`)
+ : `:${data.port}`;
+ console.log(` ${name} ${portInfo}`);
}
- console.log('')
+ console.log('');
}
console.log(`Usage: npx dev [name]
@@ -1086,53 +1168,53 @@ Examples:
npx dev restart api Restart API server
npx dev status Show all running servers
npx dev logs web Follow web server logs
-`)
+`);
}
// Main CLI
-const { command, serverName, logViewer } = parseArguments()
-const logViewerCommand = getLogViewerCommand(logViewer)
+const { command, serverName, logViewer } = parseArguments();
+const logViewerCommand = getLogViewerCommand(logViewer);
// Handle commands that don't need servers.json first
if (command === 'init') {
- initializeDevEnvironment()
- process.exit(0)
+ initializeDevEnvironment();
+ process.exit(0);
}
if (command === 'help') {
- showHelp()
- process.exit(0)
+ showHelp();
+ process.exit(0);
}
// Now load config for commands that need it
-const servers = loadServersConfig()
+const servers = loadServersConfig();
// Determine if first arg is a command or a server name (shorthand)
-const isKnownCommand = command && COMMANDS.includes(command)
-const isServerName = command && servers[command]
+const isKnownCommand = command && COMMANDS.includes(command);
+const isServerName = command && servers[command];
// Resolve actual command and server
-let actualCommand: string
-let actualServerName: string | null
+let actualCommand: string;
+let actualServerName: string | null;
if (isKnownCommand) {
// Command-first: npx dev start web
- actualCommand = command
- actualServerName = serverName
+ actualCommand = command;
+ actualServerName = serverName;
} else if (isServerName) {
// Shorthand: npx dev web → npx dev start web
- actualCommand = 'start'
- actualServerName = command
+ actualCommand = 'start';
+ actualServerName = command;
} else if (!command) {
// No args: npx dev → npx dev start (first server)
- actualCommand = 'start'
- actualServerName = null
+ actualCommand = 'start';
+ actualServerName = null;
} else {
// Unknown command
- console.error(`Unknown command or server: ${command}`)
- console.log('')
- showHelp()
- process.exit(1)
+ console.error(`Unknown command or server: ${command}`);
+ console.log('');
+ showHelp();
+ process.exit(1);
}
// Execute command
@@ -1140,65 +1222,65 @@ switch (actualCommand) {
case 'start': {
if (!actualServerName) {
// Start first server in config
- const firstServer = Object.keys(servers)[0]
+ const firstServer = Object.keys(servers)[0];
if (firstServer) {
- await startServer(firstServer, logViewerCommand)
+ await startServer(firstServer, logViewerCommand);
} else {
- console.error('No servers configured in .dev/servers.json')
- process.exit(1)
+ console.error('No servers configured in .dev/servers.json');
+ process.exit(1);
}
} else {
if (!servers[actualServerName]) {
- console.error(`Unknown server: ${actualServerName}`)
- console.log(`Available servers: ${Object.keys(servers).join(', ')}`)
- process.exit(1)
+ console.error(`Unknown server: ${actualServerName}`);
+ console.log(`Available servers: ${Object.keys(servers).join(', ')}`);
+ process.exit(1);
}
- await startServer(actualServerName, logViewerCommand)
+ await startServer(actualServerName, logViewerCommand);
}
- break
+ break;
}
case 'stop':
- await stopServers(actualServerName)
- break
+ await stopServers(actualServerName);
+ break;
case 'restart': {
if (!actualServerName) {
- console.error('Server name required for restart')
- console.log('Usage: npx dev restart ')
- process.exit(1)
+ console.error('Server name required for restart');
+ console.log('Usage: npx dev restart ');
+ process.exit(1);
}
if (!servers[actualServerName]) {
- console.error(`Unknown server: ${actualServerName}`)
- console.log(`Available servers: ${Object.keys(servers).join(', ')}`)
- process.exit(1)
+ console.error(`Unknown server: ${actualServerName}`);
+ console.log(`Available servers: ${Object.keys(servers).join(', ')}`);
+ process.exit(1);
}
- await stopServers(actualServerName)
- await startServer(actualServerName, logViewerCommand)
- break
+ await stopServers(actualServerName);
+ await startServer(actualServerName, logViewerCommand);
+ break;
}
case 'status':
- await listServers()
- break
+ await listServers();
+ break;
case 'logs':
- await showLogs(actualServerName)
- break
+ await showLogs(actualServerName);
+ break;
case 'cleanup':
- await cleanup()
- break
+ await cleanup();
+ break;
case 'doctor':
- await doctor()
- break
+ await doctor();
+ break;
case 'help':
- showHelp()
- break
+ showHelp();
+ break;
default:
- showHelp()
- break
+ showHelp();
+ break;
}
diff --git a/bin/notify.ts b/bin/notify.ts
index 3ae12f7..e6d8e38 100644
--- a/bin/notify.ts
+++ b/bin/notify.ts
@@ -3,18 +3,18 @@
* Uses node-notifier for native OS notifications
*/
-import path from 'path'
-import { fileURLToPath } from 'url'
-import notifier from 'node-notifier'
+import path from 'path';
+import { fileURLToPath } from 'url';
+import notifier from 'node-notifier';
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
-export type NotificationLevel = 'info' | 'success' | 'warning' | 'error'
+export type NotificationLevel = 'info' | 'success' | 'warning' | 'error';
export interface NotificationOptions {
- title: string
- message: string
- level?: NotificationLevel
+ title: string;
+ message: string;
+ level?: NotificationLevel;
}
/**
@@ -22,13 +22,13 @@ export interface NotificationOptions {
* Can be disabled by setting ENABLE_NOTIFICATIONS=false
*/
export function notify(options: NotificationOptions): void {
- const enabled = process.env.ENABLE_NOTIFICATIONS !== 'false'
+ const enabled = process.env.ENABLE_NOTIFICATIONS !== 'false';
if (!enabled) {
- return
+ return;
}
- const { title, message, level = 'info' } = options
+ const { title, message, level = 'info' } = options;
// Add emoji prefix based on level
const prefix = {
@@ -36,7 +36,7 @@ export function notify(options: NotificationOptions): void {
success: '✅',
warning: '⚠️',
error: '❌',
- }[level]
+ }[level];
notifier.notify({
title,
@@ -47,33 +47,33 @@ export function notify(options: NotificationOptions): void {
// Wait for notification to be dismissed or timeout
wait: false,
timeout: 5,
- })
+ });
}
/**
* Convenience method for info notifications
*/
export function notifyInfo(title: string, message: string): void {
- notify({ title, message, level: 'info' })
+ notify({ title, message, level: 'info' });
}
/**
* Convenience method for success notifications
*/
export function notifySuccess(title: string, message: string): void {
- notify({ title, message, level: 'success' })
+ notify({ title, message, level: 'success' });
}
/**
* Convenience method for warning notifications
*/
export function notifyWarning(title: string, message: string): void {
- notify({ title, message, level: 'warning' })
+ notify({ title, message, level: 'warning' });
}
/**
* Convenience method for error notifications
*/
export function notifyError(title: string, message: string): void {
- notify({ title, message, level: 'error' })
+ notify({ title, message, level: 'error' });
}
diff --git a/doc/decisions/002-vitest-testing-strategy.md b/doc/decisions/002-vitest-testing-strategy.md
new file mode 100644
index 0000000..8d4eccf
--- /dev/null
+++ b/doc/decisions/002-vitest-testing-strategy.md
@@ -0,0 +1,108 @@
+# 002. Vitest Testing Strategy
+
+Date: 2026-01-12
+
+## Status
+
+Accepted
+
+## Context
+
+ADR-001 established a single-file TypeScript CLI architecture, deliberately removing the previous Jest-based test suite. The rationale was that the implementation had been "battle-tested through plugin usage."
+
+However, as the project matures and becomes the canonical implementation for `@wilmoore/dev`, several factors drive the need for automated testing:
+
+1. **Backlog item #2** explicitly requested E2E testing
+2. **Regression prevention** as features are added or modified
+3. **CI/CD integration** for quality gates on pull requests
+4. **Documentation** through executable test specifications
+5. **Confidence** when refactoring the ~1300-line single file
+
+The previous test approach (Jest with modular architecture) was removed because it was tied to the modular architecture. A fresh testing strategy was needed that:
+- Works with the current single-file architecture
+- Supports ESM and TypeScript without compilation
+- Handles process management testing (spawning, killing)
+- Provides fast feedback for pure function unit tests
+
+## Decision
+
+Adopt **Vitest** as the testing framework with a three-tier testing strategy:
+
+### 1. Unit Tests (Pure Functions)
+Fast, isolated tests for deterministic functions:
+- `sanitizeServerName()` - String transformation
+- `parseArguments()` - CLI argument parsing
+- `detectPortFromOutput()` - Regex pattern matching
+- `getLogViewerCommand()` - Config resolution
+
+### 2. Integration Tests (Config & HTTP)
+Tests requiring real resources but no process spawning:
+- Config file structure validation
+- Template variable replacement
+- Health check with mock HTTP server
+
+### 3. E2E Tests (Full CLI)
+Complete workflow tests using the actual CLI:
+- `help` command output
+- `status` with empty pid.json
+- `doctor` configuration display
+- `cleanup` of stale entries
+- `logs` when server not running
+- `stop` for non-running servers
+- Error handling for missing config
+
+### Test Infrastructure
+- **Vitest**: Modern, fast, native ESM support
+- **Mock HTTP server**: For health check testing
+- **CLI runner helper**: Execute CLI and capture output
+- **Config backup/restore**: Prevent test pollution
+
+### CI/CD
+GitHub Actions workflow running on:
+- Ubuntu and macOS
+- Node.js 18, 20, 22
+- Format check, unit, integration, and E2E tests
+- Coverage reporting via Codecov
+
+## Consequences
+
+### Positive
+- **Regression prevention**: Automated tests catch breaking changes
+- **Documentation**: Tests serve as executable specifications
+- **Confidence**: Safe to refactor the large single file
+- **CI/CD**: Quality gates prevent merging broken code
+- **Fast feedback**: Unit tests run in milliseconds
+
+### Negative
+- **Maintenance overhead**: Tests require updates when behavior changes
+- **Config file constraints**: E2E tests share project's .dev/ directory
+- **Port conflicts**: E2E tests need careful port isolation
+- **Function duplication**: Unit tests duplicate function code from dev.ts
+
+### Trade-offs
+- **No function exports**: Pure functions are duplicated in unit tests rather than exported from dev.ts (preserves single-file simplicity)
+- **Sequential E2E**: Tests run sequentially to avoid config file conflicts (slower but more reliable)
+- **Local testing only**: Tests the local bin/dev.ts, not the published npm package
+
+## Alternatives Considered
+
+### Node.js Built-in Test Runner
+- Rejected: Less ecosystem support, fewer features than Vitest
+
+### Jest
+- Rejected: Slower startup, ESM support still maturing
+
+### Export functions for testing
+- Rejected: Would clutter the module's API; duplication is acceptable for unit tests
+
+### Test published package
+- Rejected: Adds publish-test cycle complexity; local testing sufficient for CI
+
+### No Tests (Status Quo)
+- Rejected: Technical debt accumulates, regressions likely
+
+## Related
+
+- ADR-001: Single-file TypeScript CLI Architecture
+- Backlog item #2: E2E testing request
+- `.plan/feature-automated-testing-strategy/PLAN.md`: Implementation planning
diff --git a/doc/decisions/README.md b/doc/decisions/README.md
index 78a3216..d3555e7 100644
--- a/doc/decisions/README.md
+++ b/doc/decisions/README.md
@@ -19,3 +19,4 @@ We use the [Michael Nygard format](https://cognitect.com/blog/2011/11/15/documen
## Index
- [001. Single-file TypeScript CLI Architecture](001-single-file-typescript-cli-architecture.md)
+- [002. Vitest Testing Strategy](002-vitest-testing-strategy.md)
diff --git a/package-lock.json b/package-lock.json
index 9b05969..481fa6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@wilmoore/dev",
- "version": "2.0.0",
+ "version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@wilmoore/dev",
- "version": "2.0.0",
+ "version": "2.1.0",
"license": "MIT",
"os": [
"darwin",
@@ -20,12 +20,88 @@
"dev": "bin/dev.ts"
},
"devDependencies": {
- "prettier": "^3.6.2"
+ "@vitest/coverage-v8": "^3.2.4",
+ "prettier": "^3.6.2",
+ "vitest": "^3.2.4"
},
"engines": {
"node": ">=18.0.0"
}
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -442,210 +518,2055 @@
"node": ">=18"
}
},
- "node_modules/esbuild": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
- "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.2",
- "@esbuild/android-arm": "0.27.2",
- "@esbuild/android-arm64": "0.27.2",
- "@esbuild/android-x64": "0.27.2",
- "@esbuild/darwin-arm64": "0.27.2",
- "@esbuild/darwin-x64": "0.27.2",
- "@esbuild/freebsd-arm64": "0.27.2",
- "@esbuild/freebsd-x64": "0.27.2",
- "@esbuild/linux-arm": "0.27.2",
- "@esbuild/linux-arm64": "0.27.2",
- "@esbuild/linux-ia32": "0.27.2",
- "@esbuild/linux-loong64": "0.27.2",
- "@esbuild/linux-mips64el": "0.27.2",
- "@esbuild/linux-ppc64": "0.27.2",
- "@esbuild/linux-riscv64": "0.27.2",
- "@esbuild/linux-s390x": "0.27.2",
- "@esbuild/linux-x64": "0.27.2",
- "@esbuild/netbsd-arm64": "0.27.2",
- "@esbuild/netbsd-x64": "0.27.2",
- "@esbuild/openbsd-arm64": "0.27.2",
- "@esbuild/openbsd-x64": "0.27.2",
- "@esbuild/openharmony-arm64": "0.27.2",
- "@esbuild/sunos-x64": "0.27.2",
- "@esbuild/win32-arm64": "0.27.2",
- "@esbuild/win32-ia32": "0.27.2",
- "@esbuild/win32-x64": "0.27.2"
+ "node": ">=12"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
"engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">=8"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.13.0",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
- "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
- "node_modules/growly": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
- "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
- "license": "MIT"
- },
- "node_modules/is-docker": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
- "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
- "license": "MIT",
- "bin": {
- "is-docker": "cli.js"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/is-wsl": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
- "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "is-docker": "^2.0.0"
- },
"engines": {
- "node": ">=8"
+ "node": ">=6.0.0"
}
},
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "license": "ISC"
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
},
- "node_modules/node-notifier": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
- "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "growly": "^1.3.0",
- "is-wsl": "^2.2.0",
- "semver": "^7.3.5",
- "shellwords": "^0.1.1",
- "uuid": "^8.3.2",
- "which": "^2.0.2"
- }
- },
- "node_modules/node-notifier/node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/prettier": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
- "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
+ "optional": true,
"engines": {
"node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
}
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
+ "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
+ "optional": true,
+ "os": [
+ "android"
+ ]
},
- "node_modules/shellwords": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
- "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
- "license": "MIT"
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
+ "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
},
- "node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
+ "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
},
- "node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
+ "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
},
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
+ "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
+ "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
+ "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
+ "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
+ "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
+ "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
+ "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
+ "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
+ "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
+ "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
+ "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
+ "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
+ "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
+ "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
+ "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
+ "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
+ "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
+ "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/expect/node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+ "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/growly": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
+ "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-notifier": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
+ "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "growly": "^1.3.0",
+ "is-wsl": "^2.2.0",
+ "semver": "^7.3.5",
+ "shellwords": "^0.1.1",
+ "uuid": "^8.3.2",
+ "which": "^2.0.2"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.55.1",
+ "@rollup/rollup-android-arm64": "4.55.1",
+ "@rollup/rollup-darwin-arm64": "4.55.1",
+ "@rollup/rollup-darwin-x64": "4.55.1",
+ "@rollup/rollup-freebsd-arm64": "4.55.1",
+ "@rollup/rollup-freebsd-x64": "4.55.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.55.1",
+ "@rollup/rollup-linux-arm64-musl": "4.55.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.55.1",
+ "@rollup/rollup-linux-loong64-musl": "4.55.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.55.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.55.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-musl": "4.55.1",
+ "@rollup/rollup-openbsd-x64": "4.55.1",
+ "@rollup/rollup-openharmony-arm64": "4.55.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.55.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.55.1",
+ "@rollup/rollup-win32-x64-gnu": "4.55.1",
+ "@rollup/rollup-win32-x64-msvc": "4.55.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shellwords": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
+ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/test-exclude": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
}
}
}
diff --git a/package.json b/package.json
index e3e4cb5..689f854 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@wilmoore/dev",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "A sophisticated development server management tool with process monitoring, health checks, and log management",
"keywords": [
"dev-server",
@@ -38,7 +38,13 @@
"start": "tsx bin/dev.ts",
"format": "prettier --write bin/ *.md",
"format:check": "prettier --check bin/ *.md",
- "prepare": "chmod +x bin/dev.ts"
+ "prepare": "chmod +x bin/dev.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:unit": "vitest run test/unit",
+ "test:integration": "vitest run test/integration",
+ "test:e2e": "vitest run test/e2e",
+ "test:coverage": "vitest run --coverage"
},
"engines": {
"node": ">=18.0.0"
@@ -48,7 +54,9 @@
"tsx": "^4.20.6"
},
"devDependencies": {
- "prettier": "^3.6.2"
+ "@vitest/coverage-v8": "^3.2.4",
+ "prettier": "^3.6.2",
+ "vitest": "^3.2.4"
},
"preferGlobal": true,
"os": [
diff --git a/test/e2e/cli-commands.test.ts b/test/e2e/cli-commands.test.ts
new file mode 100644
index 0000000..fc5af26
--- /dev/null
+++ b/test/e2e/cli-commands.test.ts
@@ -0,0 +1,203 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCLI, killProcessOnPort } from '../helpers/cli-runner';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(__dirname, '../..');
+const devDir = path.join(projectRoot, '.dev');
+const serversConfigPath = path.join(devDir, 'servers.json');
+const pidFilePath = path.join(devDir, 'pid.json');
+
+/**
+ * E2E tests for individual CLI commands.
+ */
+describe('CLI Commands', () => {
+ let originalServersConfig: string | null = null;
+ let originalPidFile: string | null = null;
+
+ beforeAll(async () => {
+ // Ensure .dev directory exists (may not exist in CI)
+ await fs.mkdir(devDir, { recursive: true });
+
+ try {
+ originalServersConfig = await fs.readFile(serversConfigPath, 'utf-8');
+ } catch {
+ originalServersConfig = null;
+ }
+ try {
+ originalPidFile = await fs.readFile(pidFilePath, 'utf-8');
+ } catch {
+ originalPidFile = null;
+ }
+ });
+
+ afterAll(async () => {
+ await runCLI(['stop']).catch(() => {});
+ await killProcessOnPort(3458);
+ await killProcessOnPort(3459);
+ await new Promise(r => setTimeout(r, 1000));
+
+ if (originalServersConfig !== null) {
+ await fs.writeFile(serversConfigPath, originalServersConfig);
+ }
+ if (originalPidFile !== null) {
+ await fs.writeFile(pidFilePath, originalPidFile);
+ } else {
+ await fs.writeFile(pidFilePath, '{}');
+ }
+ });
+
+ describe('unknown command', () => {
+ it('should show help for unknown commands', async () => {
+ // Ensure valid config first
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['unknowncommand']);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('Unknown command or server');
+ expect(result.stdout).toContain('Usage: npx dev');
+ });
+ });
+
+ describe('start command', () => {
+ it('should error for non-existent server name', async () => {
+ const testConfig = {
+ realserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['start', 'nonexistent']);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('Unknown server: nonexistent');
+ });
+ });
+
+ describe('restart command', () => {
+ it('should require server name', async () => {
+ // Ensure valid config
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['restart']);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('Server name required for restart');
+ });
+ });
+
+ describe('logs command', () => {
+ it('should report when server is not running', async () => {
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['logs', 'testserver']);
+
+ // Logs command checks if server is in pid.json
+ expect(result.stdout).toContain("Server 'testserver' is not running");
+ });
+
+ it('should report when no servers running and no name specified', async () => {
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['logs']);
+
+ expect(result.stdout).toContain('No servers running');
+ });
+ });
+
+ describe('stop command', () => {
+ it('should handle stopping when no servers running', async () => {
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['stop']);
+
+ // Should succeed with no servers to stop
+ expect(result.exitCode).toBe(0);
+ });
+
+ it('should handle stopping specific non-running server', async () => {
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3458,
+ healthCheck: 'http://localhost:3458',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['stop', 'testserver']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('testserver is not running');
+ });
+ });
+});
diff --git a/test/e2e/cli-workflow.test.ts b/test/e2e/cli-workflow.test.ts
new file mode 100644
index 0000000..f4f48f4
--- /dev/null
+++ b/test/e2e/cli-workflow.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCLI, killProcessOnPort } from '../helpers/cli-runner';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(__dirname, '../..');
+const devDir = path.join(projectRoot, '.dev');
+const serversConfigPath = path.join(devDir, 'servers.json');
+const pidFilePath = path.join(devDir, 'pid.json');
+
+/**
+ * E2E tests for complete CLI workflows.
+ *
+ * These tests backup/restore config once for the entire suite
+ * to avoid test interference.
+ */
+describe('CLI Workflow E2E', () => {
+ let originalServersConfig: string | null = null;
+ let originalPidFile: string | null = null;
+
+ // Backup once before all tests
+ beforeAll(async () => {
+ // Ensure .dev directory exists (may not exist in CI)
+ await fs.mkdir(devDir, { recursive: true });
+
+ try {
+ originalServersConfig = await fs.readFile(serversConfigPath, 'utf-8');
+ } catch {
+ originalServersConfig = null;
+ }
+ try {
+ originalPidFile = await fs.readFile(pidFilePath, 'utf-8');
+ } catch {
+ originalPidFile = null;
+ }
+ });
+
+ // Restore once after all tests
+ afterAll(async () => {
+ // Stop any servers
+ await runCLI(['stop']).catch(() => {});
+ await killProcessOnPort(3456);
+ await new Promise(r => setTimeout(r, 1000));
+
+ // Restore config
+ if (originalServersConfig !== null) {
+ await fs.writeFile(serversConfigPath, originalServersConfig);
+ }
+ if (originalPidFile !== null) {
+ await fs.writeFile(pidFilePath, originalPidFile);
+ } else {
+ await fs.writeFile(pidFilePath, '{}');
+ }
+ });
+
+ describe('help command', () => {
+ it('should display usage information', async () => {
+ // Ensure valid config exists
+ const testConfig = {
+ testapp: {
+ command: 'npm run dev',
+ preferredPort: 3000,
+ healthCheck: 'http://localhost:{PORT}',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+
+ const result = await runCLI(['help']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('Usage: npx dev');
+ expect(result.stdout).toContain('Commands:');
+ expect(result.stdout).toContain('start');
+ expect(result.stdout).toContain('stop');
+ expect(result.stdout).toContain('status');
+ });
+ });
+
+ describe('status command', () => {
+ it('should report no servers when pid.json is empty', async () => {
+ // Ensure valid config exists
+ const testConfig = {
+ testapp: {
+ command: 'npm run dev',
+ preferredPort: 3000,
+ healthCheck: 'http://localhost:{PORT}',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['status']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('No servers running');
+ });
+ });
+
+ describe('doctor command', () => {
+ it('should show configuration', async () => {
+ // Ensure we have valid config
+ const testConfig = {
+ testapp: {
+ command: 'npm run dev',
+ preferredPort: 3000,
+ healthCheck: 'http://localhost:{PORT}',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+
+ const result = await runCLI(['doctor']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('Configuration:');
+ });
+ });
+
+ describe('cleanup command', () => {
+ it('should complete without error when pid.json is empty', async () => {
+ await fs.writeFile(pidFilePath, '{}');
+
+ const result = await runCLI(['cleanup']);
+
+ expect(result.exitCode).toBe(0);
+ });
+
+ it('should remove stale pid entries', async () => {
+ // First ensure valid config
+ const testConfig = {
+ staleserver: {
+ command: 'npm run dev',
+ preferredPort: 3456,
+ healthCheck: 'http://localhost:3456',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+
+ // Create a pid entry with a non-existent process
+ const stalePidData = {
+ staleserver: {
+ pid: 999999, // Very unlikely to exist
+ port: 3456,
+ startTime: new Date().toISOString(),
+ status: 'healthy',
+ },
+ };
+ await fs.writeFile(pidFilePath, JSON.stringify(stalePidData, null, 2));
+
+ // Run cleanup
+ const result = await runCLI(['cleanup']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('Cleaned up stale entry: staleserver');
+
+ // Verify pid.json is now empty
+ const pidContent = await fs.readFile(pidFilePath, 'utf-8');
+ expect(JSON.parse(pidContent)).toEqual({});
+ });
+ });
+});
diff --git a/test/e2e/error-handling.test.ts b/test/e2e/error-handling.test.ts
new file mode 100644
index 0000000..e0194fa
--- /dev/null
+++ b/test/e2e/error-handling.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCLI, killProcessOnPort } from '../helpers/cli-runner';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(__dirname, '../..');
+const devDir = path.join(projectRoot, '.dev');
+const serversConfigPath = path.join(devDir, 'servers.json');
+const pidFilePath = path.join(devDir, 'pid.json');
+
+/**
+ * E2E tests for error handling and edge cases.
+ */
+describe('Error Handling', () => {
+ let originalServersConfig: string | null = null;
+ let originalPidFile: string | null = null;
+
+ beforeAll(async () => {
+ // Ensure .dev directory exists (may not exist in CI)
+ await fs.mkdir(devDir, { recursive: true });
+
+ try {
+ originalServersConfig = await fs.readFile(serversConfigPath, 'utf-8');
+ } catch {
+ originalServersConfig = null;
+ }
+ try {
+ originalPidFile = await fs.readFile(pidFilePath, 'utf-8');
+ } catch {
+ originalPidFile = null;
+ }
+ });
+
+ afterAll(async () => {
+ await runCLI(['stop']).catch(() => {});
+ await killProcessOnPort(3460);
+ await new Promise(r => setTimeout(r, 1000));
+
+ if (originalServersConfig !== null) {
+ await fs.writeFile(serversConfigPath, originalServersConfig);
+ }
+ if (originalPidFile !== null) {
+ await fs.writeFile(pidFilePath, originalPidFile);
+ } else {
+ await fs.writeFile(pidFilePath, '{}');
+ }
+ });
+
+ describe('configuration errors', () => {
+ it('should handle empty servers.json object', async () => {
+ await fs.writeFile(serversConfigPath, '{}');
+ await fs.writeFile(pidFilePath, '{}');
+
+ // Start with no servers configured
+ const result = await runCLI(['start']);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('No servers configured');
+ });
+
+ it('should suggest npx dev init when servers.json missing', async () => {
+ // Temporarily rename servers.json
+ const backupPath = serversConfigPath + '.bak';
+ await fs.rename(serversConfigPath, backupPath);
+
+ try {
+ const result = await runCLI(['status']);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('npx dev init');
+ } finally {
+ // Restore servers.json
+ await fs.rename(backupPath, serversConfigPath);
+ }
+ });
+ });
+
+ describe('process cleanup', () => {
+ it('should clean up stale pid entries on status', async () => {
+ const testConfig = {
+ staleserver: {
+ command: 'npm run dev',
+ preferredPort: 3460,
+ healthCheck: 'http://localhost:3460',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+
+ // Create stale pid entry with non-existent process
+ const stalePidData = {
+ staleserver: {
+ pid: 999998,
+ port: 3460,
+ startTime: new Date().toISOString(),
+ status: 'healthy',
+ },
+ };
+ await fs.writeFile(pidFilePath, JSON.stringify(stalePidData, null, 2));
+
+ // Status should detect dead process and clean up
+ const result = await runCLI(['status']);
+
+ // The output should indicate the server is dead
+ expect(result.stdout).toMatch(/DEAD|cleaning up/i);
+ });
+ });
+
+ describe('help variants', () => {
+ it('should show help with help command', async () => {
+ // Ensure valid config first
+ const testConfig = {
+ testserver: {
+ command: 'npm run dev',
+ preferredPort: 3460,
+ healthCheck: 'http://localhost:3460',
+ },
+ };
+ await fs.writeFile(
+ serversConfigPath,
+ JSON.stringify(testConfig, null, 2)
+ );
+
+ const result = await runCLI(['help']);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain('Usage: npx dev');
+ expect(result.stdout).toContain('Commands:');
+ });
+ });
+});
diff --git a/test/helpers/cli-runner.ts b/test/helpers/cli-runner.ts
new file mode 100644
index 0000000..e3d058a
--- /dev/null
+++ b/test/helpers/cli-runner.ts
@@ -0,0 +1,109 @@
+import { spawn, exec } from 'node:child_process';
+import { promisify } from 'node:util';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const execAsync = promisify(exec);
+
+// Get the path to the CLI script
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CLI_PATH = path.resolve(__dirname, '../../bin/dev.ts');
+
+export interface CLIResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+}
+
+export interface CLIOptions {
+ cwd?: string;
+ timeout?: number;
+ env?: Record;
+}
+
+/**
+ * Run the dev CLI with given arguments
+ */
+export async function runCLI(
+ args: string[],
+ options: CLIOptions = {}
+): Promise {
+ const { cwd = process.cwd(), timeout = 30000, env = {} } = options;
+
+ return new Promise(resolve => {
+ const proc = spawn('npx', ['tsx', CLI_PATH, ...args], {
+ cwd,
+ env: {
+ ...process.env,
+ ENABLE_NOTIFICATIONS: 'false',
+ ...env,
+ },
+ timeout,
+ shell: true,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ proc.stdout?.on('data', data => {
+ stdout += data.toString();
+ });
+
+ proc.stderr?.on('data', data => {
+ stderr += data.toString();
+ });
+
+ proc.on('close', code => {
+ resolve({
+ stdout,
+ stderr,
+ exitCode: code ?? 0,
+ });
+ });
+
+ proc.on('error', err => {
+ resolve({
+ stdout,
+ stderr: stderr + err.message,
+ exitCode: 1,
+ });
+ });
+ });
+}
+
+/**
+ * Kill any process on a specific port
+ */
+export async function killProcessOnPort(port: number): Promise {
+ try {
+ const { stdout } = await execAsync(`lsof -ti:${port}`);
+ const pids = stdout.trim().split('\n').filter(Boolean);
+ for (const pid of pids) {
+ try {
+ process.kill(parseInt(pid), 'SIGKILL');
+ } catch {
+ // Process already dead
+ }
+ }
+ } catch {
+ // No process on port
+ }
+}
+
+/**
+ * Wait for a condition with timeout
+ */
+export async function waitFor(
+ condition: () => Promise,
+ timeout = 10000,
+ interval = 100
+): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ if (await condition()) {
+ return true;
+ }
+ await new Promise(r => setTimeout(r, interval));
+ }
+ return false;
+}
diff --git a/test/helpers/mock-server.ts b/test/helpers/mock-server.ts
new file mode 100644
index 0000000..9a56df2
--- /dev/null
+++ b/test/helpers/mock-server.ts
@@ -0,0 +1,46 @@
+import http from 'node:http';
+
+export interface MockServer {
+ url: string;
+ port: number;
+ close: () => Promise;
+ setResponse: (status: number, body: string) => void;
+}
+
+/**
+ * Creates a mock HTTP server for health check testing
+ */
+export async function createMockServer(port = 0): Promise {
+ let responseStatus = 200;
+ let responseBody = 'OK';
+
+ const server = http.createServer((req, res) => {
+ res.statusCode = responseStatus;
+ res.end(responseBody);
+ });
+
+ return new Promise((resolve, reject) => {
+ server.listen(port, '127.0.0.1', () => {
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ reject(new Error('Failed to get server address'));
+ return;
+ }
+
+ resolve({
+ url: `http://127.0.0.1:${address.port}`,
+ port: address.port,
+ close: () =>
+ new Promise(res => {
+ server.close(() => res());
+ }),
+ setResponse: (status: number, body: string) => {
+ responseStatus = status;
+ responseBody = body;
+ },
+ });
+ });
+
+ server.on('error', reject);
+ });
+}
diff --git a/test/helpers/temp-project.ts b/test/helpers/temp-project.ts
new file mode 100644
index 0000000..e721791
--- /dev/null
+++ b/test/helpers/temp-project.ts
@@ -0,0 +1,69 @@
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+export interface TempProject {
+ dir: string;
+ devDir: string;
+ cleanup: () => Promise;
+}
+
+export interface TempProjectOptions {
+ packageJson?: Record;
+ serversConfig?: Record;
+ pidData?: Record;
+}
+
+/**
+ * Creates an isolated temporary project directory for testing
+ */
+export async function createTempProject(
+ options: TempProjectOptions = {}
+): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-test-'));
+ const devDir = path.join(dir, '.dev');
+
+ // Create .dev directory
+ await fs.mkdir(devDir, { recursive: true });
+ await fs.mkdir(path.join(devDir, 'log'), { recursive: true });
+
+ // Default package.json with a simple HTTP server script
+ const packageJson = options.packageJson ?? {
+ name: 'test-project',
+ version: '1.0.0',
+ scripts: {
+ dev: "node -e \"const h=require('http');h.createServer((q,s)=>{s.end('ok')}).listen(process.env.PORT||3000,()=>console.log('Local: http://localhost:'+(process.env.PORT||3000)))\"",
+ api: "node -e \"const h=require('http');h.createServer((q,s)=>{s.end('ok')}).listen(process.env.PORT||3001,()=>console.log('Local: http://localhost:'+(process.env.PORT||3001)))\"",
+ },
+ };
+ await fs.writeFile(
+ path.join(dir, 'package.json'),
+ JSON.stringify(packageJson, null, 2)
+ );
+
+ // Optional servers.json
+ if (options.serversConfig) {
+ await fs.writeFile(
+ path.join(devDir, 'servers.json'),
+ JSON.stringify(options.serversConfig, null, 2)
+ );
+ }
+
+ // Optional pid.json
+ if (options.pidData) {
+ await fs.writeFile(
+ path.join(devDir, 'pid.json'),
+ JSON.stringify(options.pidData, null, 2)
+ );
+ }
+
+ const cleanup = async () => {
+ try {
+ await fs.rm(dir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ };
+
+ return { dir, devDir, cleanup };
+}
diff --git a/test/integration/config-loading.test.ts b/test/integration/config-loading.test.ts
new file mode 100644
index 0000000..277f9d0
--- /dev/null
+++ b/test/integration/config-loading.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(__dirname, '../..');
+const devDir = path.join(projectRoot, '.dev');
+const serversConfigPath = path.join(devDir, 'servers.json');
+const pidFilePath = path.join(devDir, 'pid.json');
+
+/**
+ * Integration tests for configuration loading.
+ *
+ * NOTE: The dev.ts CLI uses __dirname-relative paths, so it always operates
+ * on the project's own .dev/ directory, not the temp project directory.
+ * These tests work directly with the project's config files.
+ *
+ * For isolated testing, we backup and restore the config files.
+ */
+describe('Configuration Loading', () => {
+ let originalServersConfig: string | null = null;
+ let originalPidFile: string | null = null;
+
+ // Backup existing config before tests
+ beforeAll(async () => {
+ try {
+ originalServersConfig = await fs.readFile(serversConfigPath, 'utf-8');
+ } catch {
+ originalServersConfig = null;
+ }
+ try {
+ originalPidFile = await fs.readFile(pidFilePath, 'utf-8');
+ } catch {
+ originalPidFile = null;
+ }
+ });
+
+ // Restore original config after all tests
+ afterEach(async () => {
+ // Restore servers.json
+ if (originalServersConfig !== null) {
+ await fs.writeFile(serversConfigPath, originalServersConfig);
+ }
+ // Restore pid.json
+ if (originalPidFile !== null) {
+ await fs.writeFile(pidFilePath, originalPidFile);
+ }
+ });
+
+ describe('servers.json parsing', () => {
+ it('should parse valid JSON configuration', async () => {
+ const config = {
+ web: {
+ command: 'npm run dev',
+ preferredPort: 3000,
+ healthCheck: 'http://localhost:{PORT}',
+ },
+ api: {
+ command: 'npm run api',
+ preferredPort: 3001,
+ healthCheck: 'http://localhost:{PORT}/health',
+ },
+ };
+
+ // Read it back and verify structure
+ const parsed = JSON.parse(JSON.stringify(config)) as typeof config;
+
+ expect(parsed.web).toBeDefined();
+ expect(parsed.web.command).toBe('npm run dev');
+ expect(parsed.web.preferredPort).toBe(3000);
+ expect(parsed.api.healthCheck).toBe('http://localhost:{PORT}/health');
+ });
+
+ it('should handle servers with template variables', async () => {
+ const config = {
+ dev: {
+ command: 'PORT={PORT} npm run dev',
+ preferredPort: 3000,
+ healthCheck: 'http://localhost:{PORT}',
+ },
+ };
+
+ const command = config.dev.command.replace(/{PORT}/g, '3000');
+ expect(command).toBe('PORT=3000 npm run dev');
+
+ const healthCheck = config.dev.healthCheck.replace(/{PORT}/g, '3000');
+ expect(healthCheck).toBe('http://localhost:3000');
+ });
+ });
+
+ describe('pid.json structure', () => {
+ it('should handle empty pid data', async () => {
+ const pidData: Record = {};
+ expect(Object.keys(pidData).length).toBe(0);
+ });
+
+ it('should validate pid entry structure', async () => {
+ const pidEntry = {
+ pid: 12345,
+ port: 3000,
+ startTime: new Date().toISOString(),
+ status: 'healthy',
+ };
+
+ expect(pidEntry.pid).toBeTypeOf('number');
+ expect(pidEntry.port).toBeTypeOf('number');
+ expect(pidEntry.startTime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ expect(['healthy', 'unhealthy', 'starting']).toContain(pidEntry.status);
+ });
+
+ it('should serialize and deserialize pid data correctly', async () => {
+ const pidData = {
+ web: {
+ pid: 12345,
+ port: 3000,
+ startTime: '2024-01-01T12:00:00.000Z',
+ status: 'healthy',
+ },
+ };
+
+ const serialized = JSON.stringify(pidData, null, 2);
+ const parsed = JSON.parse(serialized);
+
+ expect(parsed.web.pid).toBe(12345);
+ expect(parsed.web.port).toBe(3000);
+ });
+ });
+
+ describe('log path generation', () => {
+ it('should sanitize server name for log file path', () => {
+ // Test the sanitization logic
+ const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9-_]/g, '-');
+
+ expect(sanitize('web')).toBe('web');
+ expect(sanitize('my server')).toBe('my-server');
+ expect(sanitize('api@v2')).toBe('api-v2');
+ });
+
+ it('should generate correct log file path', () => {
+ const serverName = 'web';
+ const sanitized = serverName.replace(/[^a-zA-Z0-9-_]/g, '-');
+ const logPath = path.join(devDir, 'log', `${sanitized}.log`);
+
+ expect(logPath).toContain('.dev/log/web.log');
+ });
+ });
+});
diff --git a/test/integration/health-check.test.ts b/test/integration/health-check.test.ts
new file mode 100644
index 0000000..dce4aca
--- /dev/null
+++ b/test/integration/health-check.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { createMockServer, MockServer } from '../helpers/mock-server';
+
+// Duplicated healthCheck function for testing
+// In production, this would require exporting from dev.ts
+async function healthCheck(
+ url: string,
+ timeout = 10000,
+ maxRetries = 3
+): Promise {
+ const delays = [1000, 2000, 4000]; // Exponential backoff: 1s, 2s, 4s
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ const success = await new Promise(resolve => {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ fetch(url, { signal: controller.signal })
+ .then(() => {
+ clearTimeout(timeoutId);
+ resolve(true);
+ })
+ .catch(() => {
+ clearTimeout(timeoutId);
+ resolve(false);
+ });
+ });
+
+ if (success) {
+ return true;
+ }
+
+ // Wait before retry (except on last attempt)
+ if (attempt < maxRetries - 1) {
+ await new Promise(resolve => setTimeout(resolve, delays[attempt]));
+ }
+ }
+
+ return false;
+}
+
+describe('healthCheck', () => {
+ let server: MockServer;
+
+ beforeEach(async () => {
+ server = await createMockServer();
+ });
+
+ afterEach(async () => {
+ await server.close();
+ });
+
+ it('should return true for healthy server (200)', async () => {
+ server.setResponse(200, 'OK');
+ const result = await healthCheck(server.url, 5000, 1);
+ expect(result).toBe(true);
+ });
+
+ it('should return true for any successful HTTP response', async () => {
+ // Health check passes on any non-error response
+ server.setResponse(201, 'Created');
+ const result = await healthCheck(server.url, 5000, 1);
+ expect(result).toBe(true);
+ });
+
+ it('should return true even for 500 errors', async () => {
+ // The healthCheck implementation considers any HTTP response as success
+ // (it only fails on connection errors)
+ server.setResponse(500, 'Internal Server Error');
+ const result = await healthCheck(server.url, 5000, 1);
+ expect(result).toBe(true);
+ });
+
+ it('should return false for unreachable server', async () => {
+ // Close the server to make it unreachable
+ await server.close();
+
+ const result = await healthCheck(server.url, 1000, 1);
+ expect(result).toBe(false);
+ });
+
+ it('should timeout on slow responses', async () => {
+ // This tests that the AbortController timeout works
+ const slowServer = await createMockServer();
+
+ // Create a very short timeout
+ const result = await healthCheck('http://10.255.255.1:12345', 100, 1);
+ expect(result).toBe(false);
+
+ await slowServer.close();
+ });
+
+ it('should retry on failure', async () => {
+ let attempts = 0;
+
+ // Create a server that fails first then succeeds
+ const retryServer = await createMockServer();
+
+ // We can't easily mock retry behavior without modifying the server
+ // But we can verify the function eventually returns after retries
+ const startTime = Date.now();
+ const result = await healthCheck('http://10.255.255.1:12345', 100, 2);
+ const elapsed = Date.now() - startTime;
+
+ expect(result).toBe(false);
+ // With 2 retries and delays, should take at least some time
+ expect(elapsed).toBeGreaterThan(100);
+
+ await retryServer.close();
+ });
+});
diff --git a/test/setup.ts b/test/setup.ts
new file mode 100644
index 0000000..6bda9e6
--- /dev/null
+++ b/test/setup.ts
@@ -0,0 +1,6 @@
+import { beforeAll } from 'vitest';
+
+// Disable notifications during tests
+beforeAll(() => {
+ process.env.ENABLE_NOTIFICATIONS = 'false';
+});
diff --git a/test/unit/detect-port-from-output.test.ts b/test/unit/detect-port-from-output.test.ts
new file mode 100644
index 0000000..1e8183c
--- /dev/null
+++ b/test/unit/detect-port-from-output.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest';
+
+// Duplicated from bin/dev.ts for unit testing
+// Note: The original function is not exported from dev.ts
+function detectPortFromOutput(output: string): number | null {
+ const match = output.match(/Local:\s+http:\/\/localhost:(\d+)/);
+ return match ? parseInt(match[1]) : null;
+}
+
+describe('detectPortFromOutput', () => {
+ it('should detect port from Next.js output format', () => {
+ const output = `
+ ▲ Next.js 14.0.0
+ - Local: http://localhost:3000
+ - Network: http://192.168.1.100:3000
+ `;
+ expect(detectPortFromOutput(output)).toBe(3000);
+ });
+
+ it('should detect port from simple format', () => {
+ const output = '- Local: http://localhost:8080';
+ expect(detectPortFromOutput(output)).toBe(8080);
+ });
+
+ it('should detect port with varying whitespace', () => {
+ const output = 'Local: http://localhost:4000';
+ expect(detectPortFromOutput(output)).toBe(4000);
+ });
+
+ it('should detect high port numbers', () => {
+ const output = 'Local: http://localhost:65535';
+ expect(detectPortFromOutput(output)).toBe(65535);
+ });
+
+ it('should return null for output without port', () => {
+ expect(detectPortFromOutput('Server starting...')).toBe(null);
+ expect(detectPortFromOutput('')).toBe(null);
+ });
+
+ it('should detect port from Vite-like output format', () => {
+ // Vite uses a slightly different format but the regex is flexible
+ const viteOutput = `
+ VITE v5.0.0 ready in 200 ms
+
+ ➜ Local: http://localhost:5173/
+ `;
+ // The regex is flexible enough to match this format
+ expect(detectPortFromOutput(viteOutput)).toBe(5173);
+ });
+
+ it('should not match non-localhost URLs', () => {
+ const output = 'Local: http://127.0.0.1:3000';
+ expect(detectPortFromOutput(output)).toBe(null);
+ });
+
+ it('should return first match when multiple ports present', () => {
+ const output = `
+ - Local: http://localhost:3000
+ - Local: http://localhost:3001
+ `;
+ expect(detectPortFromOutput(output)).toBe(3000);
+ });
+});
diff --git a/test/unit/get-log-viewer-command.test.ts b/test/unit/get-log-viewer-command.test.ts
new file mode 100644
index 0000000..ac78a53
--- /dev/null
+++ b/test/unit/get-log-viewer-command.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+// Duplicated from bin/dev.ts for unit testing
+// Note: The original function is not exported from dev.ts
+function getLogViewerCommand(cliLogViewer: string | null): string {
+ return cliLogViewer || process.env.DEV_LOG_VIEWER || 'tail -f';
+}
+
+describe('getLogViewerCommand', () => {
+ const originalEnv = process.env.DEV_LOG_VIEWER;
+
+ beforeEach(() => {
+ delete process.env.DEV_LOG_VIEWER;
+ });
+
+ afterEach(() => {
+ if (originalEnv !== undefined) {
+ process.env.DEV_LOG_VIEWER = originalEnv;
+ } else {
+ delete process.env.DEV_LOG_VIEWER;
+ }
+ });
+
+ it('should prioritize CLI argument over env var', () => {
+ process.env.DEV_LOG_VIEWER = 'env-viewer';
+ expect(getLogViewerCommand('cli-viewer')).toBe('cli-viewer');
+ });
+
+ it('should use env var when no CLI argument', () => {
+ process.env.DEV_LOG_VIEWER = 'bat -f';
+ expect(getLogViewerCommand(null)).toBe('bat -f');
+ });
+
+ it('should default to tail -f when neither CLI nor env set', () => {
+ expect(getLogViewerCommand(null)).toBe('tail -f');
+ });
+
+ it('should handle empty string CLI argument as falsy', () => {
+ process.env.DEV_LOG_VIEWER = 'env-viewer';
+ // Empty string is falsy, should fall through to env
+ expect(getLogViewerCommand('')).toBe('env-viewer');
+ });
+
+ it('should handle whitespace-only env var', () => {
+ process.env.DEV_LOG_VIEWER = ' ';
+ // Whitespace is truthy, so it will be used
+ expect(getLogViewerCommand(null)).toBe(' ');
+ });
+
+ it('should use complex command from CLI', () => {
+ expect(getLogViewerCommand('less +F -R')).toBe('less +F -R');
+ });
+
+ it('should use complex command from env', () => {
+ process.env.DEV_LOG_VIEWER = 'bat --paging=always -f';
+ expect(getLogViewerCommand(null)).toBe('bat --paging=always -f');
+ });
+});
diff --git a/test/unit/parse-arguments.test.ts b/test/unit/parse-arguments.test.ts
new file mode 100644
index 0000000..67ee36f
--- /dev/null
+++ b/test/unit/parse-arguments.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect } from 'vitest';
+
+// Duplicated from bin/dev.ts for unit testing
+// Note: The original function is not exported from dev.ts
+function parseArguments(args: string[]) {
+ const parsed: {
+ command: string | null;
+ serverName: string | null;
+ logViewer: string | null;
+ } = {
+ command: null,
+ serverName: null,
+ logViewer: null,
+ };
+
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+
+ if (arg === '--log-viewer' && i + 1 < args.length) {
+ parsed.logViewer = args[i + 1];
+ i++; // Skip next argument as it's the value
+ } else if (!parsed.command) {
+ parsed.command = arg;
+ } else if (!parsed.serverName) {
+ parsed.serverName = arg;
+ }
+ }
+
+ return parsed;
+}
+
+describe('parseArguments', () => {
+ it('should parse command only', () => {
+ const result = parseArguments(['start']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: null,
+ logViewer: null,
+ });
+ });
+
+ it('should parse command and server name', () => {
+ const result = parseArguments(['start', 'web']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: 'web',
+ logViewer: null,
+ });
+ });
+
+ it('should parse --log-viewer flag with value', () => {
+ const result = parseArguments(['start', '--log-viewer', 'bat -f']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: null,
+ logViewer: 'bat -f',
+ });
+ });
+
+ it('should parse all arguments together', () => {
+ const result = parseArguments(['start', 'web', '--log-viewer', 'less +F']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: 'web',
+ logViewer: 'less +F',
+ });
+ });
+
+ it('should handle empty arguments', () => {
+ const result = parseArguments([]);
+ expect(result).toEqual({
+ command: null,
+ serverName: null,
+ logViewer: null,
+ });
+ });
+
+ it('should handle --log-viewer at the end without value', () => {
+ // When --log-viewer has no value, it's treated as serverName
+ // This is edge case behavior of the original implementation
+ const result = parseArguments(['start', '--log-viewer']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: '--log-viewer',
+ logViewer: null,
+ });
+ });
+
+ it('should handle --log-viewer before command', () => {
+ const result = parseArguments(['--log-viewer', 'bat -f', 'start', 'web']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: 'web',
+ logViewer: 'bat -f',
+ });
+ });
+
+ it('should handle multiple commands (only first is used)', () => {
+ const result = parseArguments(['start', 'stop', 'restart']);
+ expect(result).toEqual({
+ command: 'start',
+ serverName: 'stop',
+ logViewer: null,
+ });
+ });
+});
diff --git a/test/unit/sanitize-server-name.test.ts b/test/unit/sanitize-server-name.test.ts
new file mode 100644
index 0000000..e52aba9
--- /dev/null
+++ b/test/unit/sanitize-server-name.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+
+// Duplicated from bin/dev.ts for unit testing
+// Note: The original function is not exported from dev.ts
+function sanitizeServerName(serverName: string): string {
+ return serverName.replace(/[^a-zA-Z0-9-_]/g, '-');
+}
+
+describe('sanitizeServerName', () => {
+ it('should pass through valid names unchanged', () => {
+ expect(sanitizeServerName('web-server')).toBe('web-server');
+ expect(sanitizeServerName('api_v2')).toBe('api_v2');
+ expect(sanitizeServerName('Server123')).toBe('Server123');
+ });
+
+ it('should replace spaces with hyphens', () => {
+ expect(sanitizeServerName('my server')).toBe('my-server');
+ expect(sanitizeServerName('web app')).toBe('web-app');
+ });
+
+ it('should replace special characters with hyphens', () => {
+ expect(sanitizeServerName('server@2.0')).toBe('server-2-0');
+ expect(sanitizeServerName('web/api')).toBe('web-api');
+ expect(sanitizeServerName('test:server')).toBe('test-server');
+ });
+
+ it('should handle empty string', () => {
+ expect(sanitizeServerName('')).toBe('');
+ });
+
+ it('should handle strings with only special characters', () => {
+ expect(sanitizeServerName('@#$%')).toBe('----');
+ });
+
+ it('should preserve underscores and hyphens', () => {
+ expect(sanitizeServerName('my_server-name')).toBe('my_server-name');
+ });
+
+ it('should handle mixed alphanumeric and special characters', () => {
+ expect(sanitizeServerName('server.v1.0-beta')).toBe('server-v1-0-beta');
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..a8bd569
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,53 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ // Run TypeScript directly (matches project's tsx approach)
+ include: ['test/**/*.test.ts'],
+ exclude: ['**/node_modules/**', '**/dist/**'],
+
+ // Node environment for CLI testing
+ environment: 'node',
+
+ // Global test APIs (describe, it, expect)
+ globals: true,
+
+ // Isolation prevents test pollution
+ isolate: true,
+
+ // Fork pool for better process isolation
+ pool: 'forks',
+
+ // Timeouts for E2E tests involving processes
+ testTimeout: 30000,
+ hookTimeout: 10000,
+
+ // Clear mocks between tests
+ clearMocks: true,
+ restoreMocks: true,
+
+ // Setup file for global configuration
+ setupFiles: ['./test/setup.ts'],
+
+ // Coverage configuration
+ coverage: {
+ provider: 'v8',
+ enabled: false, // Enable with --coverage flag
+ reporter: ['text', 'html', 'lcov'],
+ reportsDirectory: './coverage',
+ include: ['bin/**/*.ts'],
+ exclude: ['**/*.test.ts', 'test/**'],
+ },
+
+ // Reporter configuration
+ reporters: ['default'],
+
+ // Sequence configuration
+ sequence: {
+ shuffle: false, // Keep tests predictable
+ },
+
+ // Run E2E tests sequentially to avoid config file conflicts
+ fileParallelism: false,
+ },
+});