diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a77ba..9d36ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Anonymous usage analytics with signed authentication (Phase 3) + - `lwp analytics status` - Show analytics status and installation ID + - `lwp analytics on` - Enable analytics + - `lwp analytics off` - Disable analytics + - `lwp analytics show` - View usage summary (or `--json` for raw events) + - `lwp analytics reset` - Delete all data and regenerate credentials + - `lwp analytics dashboard` - Open personal analytics dashboard (signed URL) +- HMAC-SHA256 signed authentication for all analytics requests +- Secret key generation (32 bytes) for request signing +- Signed dashboard URLs with 1-hour expiration +- Installation ID for anonymous tracking (random UUID, no PII) +- Session ID to correlate commands within a CLI session +- Extended event format: cli_version, os, node_version, error_category +- Server transmission to Cloudflare Workers + D1 backend +- Protected /v1/stats endpoint with ADMIN_TOKEN +- First-run opt-in prompt (defaults to opt-out in non-interactive mode) +- Auto-disable analytics in CI environments +- Command exclusions for sensitive commands (wpe.*, analytics.*) +- Analytics worker package for Cloudflare deployment + ## [0.0.5] - 2025-01-31 ### Fixed diff --git a/docs/rfcs/001-anonymous-usage-analytics.md b/docs/rfcs/001-anonymous-usage-analytics.md index fd448e7..d27d8b9 100644 --- a/docs/rfcs/001-anonymous-usage-analytics.md +++ b/docs/rfcs/001-anonymous-usage-analytics.md @@ -25,27 +25,24 @@ However, this must be balanced against user privacy and trust. 2. **Transparency** - Users know exactly what's collected 3. **User control** - Easy opt-in/opt-out at any time 4. **Data ownership** - Users can view and delete their data -5. **Security** - No data leakage between users +5. **Simplicity** - Minimal implementation for MVP, add complexity only when needed --- -## Phase 1: Local Tracking + CLI Dashboard (MVP) +## Phase 1: Local Tracking + CLI Summary (MVP) ### What We Track -#### Collected Data +#### Collected Data (Minimal) | Field | Example | Purpose | |-------|---------|---------| -| `event` | `command_executed` | Event type | | `command` | `sites.list` | Command name (no arguments) | | `success` | `true` | Did it succeed? | | `duration_ms` | `1234` | Execution time | -| `cli_version` | `0.0.5` | CLI version | -| `os` | `darwin` | Operating system | -| `node_version` | `20.10.0` | Node.js version | | `timestamp` | `2025-01-31T12:00:00Z` | When it happened | -| `session_id` | `uuid` | Groups commands in one session | + +**Note:** Phase 1 intentionally collects minimal data. Additional fields (cli_version, os, node_version, session_id, error_category) deferred to Phase 2 when events are transmitted to a server. #### Never Collected @@ -55,46 +52,42 @@ However, this must be balanced against user privacy and trust. - IP addresses - User names, emails, or any PII - Environment variables -- Error stack traces with file paths +- Error stack traces or messages -### User Identity +### Config File -On first run, lwp generates a local identity stored in `~/.lwp/config.json`: +On first run, lwp creates `~/.lwp/config.json`: ```json { - "userId": "550e8400-e29b-41d4-a716-446655440000", "analytics": { "enabled": true, - "promptedAt": "2025-01-31T12:00:00Z", - "excludeCommands": ["wpe.*", "analytics.*"] + "promptedAt": "2025-01-31T12:00:00Z" } } ``` -- `userId`: Random UUID v4, generated locally -- Never transmitted with events (events are truly anonymous) -- Used only for local event correlation and future dashboard access -- `excludeCommands`: Glob patterns for commands to never track +- Minimal config for Phase 1 +- `userId` and `secretKey` added in Phase 3 when needed for signed URLs +- File permissions set to `0600` (user read/write only) ### Command Exclusion -Certain commands are excluded from tracking by default: +Certain commands are excluded from tracking using simple prefix matching: -| Pattern | Reason | -|---------|--------| -| `wpe.*` | May contain sensitive WP Engine hosting information | -| `analytics.*` | Meta commands about analytics itself | +| Prefix | Reason | +|--------|--------| +| `wpe.` | May contain sensitive WP Engine hosting information | +| `analytics.` | Meta commands about analytics itself | -Users can add custom exclusions: - -```bash -lwp analytics exclude "db.*" # Exclude all database commands -lwp analytics exclude "wp" # Exclude wp-cli commands -lwp analytics exclude --list # Show current exclusions -lwp analytics exclude --reset # Reset to defaults +```typescript +// Simple implementation - no dependencies needed +const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; +const isExcluded = (cmd: string) => EXCLUDED_PREFIXES.some(p => cmd.startsWith(p)); ``` +**Note:** User-configurable exclusions deferred to a future phase. Users who want to exclude commands can simply disable analytics. + ### CI/CD Detection Analytics are **automatically disabled** in CI environments: @@ -117,7 +110,7 @@ Override with `LWP_ANALYTICS=1` to explicitly enable in CI. ### Opt-In Flow -#### First Run +#### First Run (Interactive Terminal) ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -133,14 +126,16 @@ Enable anonymous analytics? [Y/n]: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` +#### Non-Interactive (Scripts, CI) + +When stdin is not a TTY, default to **opt-out** without prompting. This prevents blocking automation. + #### Commands ```bash # Check current status lwp analytics status # → Analytics: enabled -# → User ID: 550e8400-... -# → Events this session: 12 # → Total events stored: 847 # Disable analytics @@ -151,21 +146,16 @@ lwp analytics off lwp analytics on # → Analytics enabled. Thank you for helping improve lwp! -# View what's being collected +# View collected data lwp analytics show -# → Shows recent events in a table +# → Shows summary of tracked commands lwp analytics show --json -# → Exports all local events as JSON +# → Exports all local events as JSON array # Delete all local data lwp analytics reset -# → Deleted 847 local events. -# → Generated new user ID. - -# See what would be sent (dry run) -lwp analytics debug -# → Shows next event payload without sending +# → Deleted 847 local events. Analytics disabled. ``` #### Environment Variable Override @@ -183,54 +173,72 @@ export LWP_ANALYTICS=0 Events stored in `~/.lwp/analytics/events.jsonl` (JSON Lines format): ```jsonl -{"event":"command_executed","command":"sites.list","success":true,"duration_ms":234,"timestamp":"2025-01-31T12:00:00Z"} -{"event":"command_executed","command":"wp","success":true,"duration_ms":1456,"timestamp":"2025-01-31T12:01:00Z"} -{"event":"command_executed","command":"sites.start","success":false,"duration_ms":5023,"timestamp":"2025-01-31T12:02:00Z"} +{"command":"sites.list","success":true,"duration_ms":234,"timestamp":"2025-01-31T12:00:00Z"} +{"command":"wp","success":true,"duration_ms":1456,"timestamp":"2025-01-31T12:01:00Z"} +{"command":"sites.start","success":false,"duration_ms":5023,"timestamp":"2025-01-31T12:02:00Z"} ``` -- Maximum 10,000 events stored locally (FIFO) -- ~1-2 MB max storage -- Rotated automatically +- Maximum 10,000 events stored locally +- ~500KB max storage (minimal event format) +- File permissions set to `0600` +- Simple size-based cleanup when limit reached -### CLI Dashboard +### CLI Summary Output ```bash lwp analytics show ``` -Output: +Output (simple text, no ASCII charts): ``` -Usage Analytics (last 30 days) -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Total Commands: 847 -Success Rate: 94.2% - -Top Commands: - sites list 234 ████████████████████ - wp 189 ████████████████ - sites start 156 █████████████ - sites stop 98 ████████ - db export 45 ████ - -Commands by Day: - Mon ████████████████████ 42 - Tue ████████████████ 35 - Wed ██████████████████ 38 - Thu ████████████ 28 - Fri ██████████████████ 37 - Sat ████ 8 - Sun ██████ 12 - -Failed Commands (last 7 days): - sites start 3 failures "Site not found" (2), "Timeout" (1) - wp 2 failures "Site not running" (2) +Analytics Summary +───────────────── +Total commands: 847 +Success rate: 94.2% + +Top commands: + sites.list 234 + wp 189 + sites.start 156 + sites.stop 98 + db.export 45 + +Recent failures: 5 in last 7 days ``` +**Note:** Fancy ASCII bar charts and day-of-week histograms deferred. Simple text output is sufficient for MVP. + --- ## Phase 2: Central Analytics Service (Future) +### Additional Event Fields + +When transmitting to server, add: + +| Field | Example | Purpose | +|-------|---------|---------| +| `cli_version` | `0.0.5` | Version adoption tracking | +| `os` | `darwin` | Platform distribution | +| `node_version` | `20.10.0` | Runtime compatibility | +| `session_id` | `uuid` | Correlate commands in session | +| `error_category` | `site_not_found` | Failure analysis | + +### Error Categories (Phase 2) + +```typescript +enum ErrorCategory { + SITE_NOT_FOUND = 'site_not_found', + SITE_NOT_RUNNING = 'site_not_running', + LOCAL_NOT_RUNNING = 'local_not_running', + TIMEOUT = 'timeout', + NETWORK_ERROR = 'network_error', + UNKNOWN = 'unknown' +} +``` + +**Note:** Error categorization deferred to Phase 2. In Phase 1, we only track `success: true/false`. + ### Architecture ``` @@ -260,14 +268,12 @@ Content-Type: application/json "cli_version": "0.0.5", "os": "darwin", "events": [ - {"event": "command_executed", "command": "sites.list", ...}, - {"event": "command_executed", "command": "wp", ...} + {"command": "sites.list", "success": true, ...}, + {"command": "wp", "success": true, ...} ] } ``` -**Note:** `userId` is NOT sent. Events are truly anonymous. - ### Aggregate Dashboard (Admin Only) Available at internal admin URL: @@ -310,6 +316,25 @@ https://analytics.lwp.dev/d/abc123?sig=hmac_signature&exp=1706749200 This link expires in 1 hour. Run this command again for a new link. ``` +### Config Updates for Phase 3 + +Add userId and secretKey to config: + +```json +{ + "userId": "550e8400-e29b-41d4-a716-446655440000", + "secretKey": "base64-encoded-32-bytes", + "analytics": { + "enabled": true, + "promptedAt": "2025-01-31T12:00:00Z" + } +} +``` + +- `userId`: Random UUID v4, generated locally +- `secretKey`: 32 random bytes for HMAC signing +- Both generated when first needed (Phase 3), not in Phase 1 + ### How It Works 1. **User runs `lwp analytics dashboard`** @@ -335,23 +360,6 @@ This link expires in 1 hour. Run this command again for a new link. | Signature tampering | HMAC validation | | Rate limiting bypass | Per-IP and per-user limits | -### User Secret Key - -Stored locally in `~/.lwp/config.json`: - -```json -{ - "userId": "uuid", - "secretKey": "base64-encoded-32-bytes", - "analytics": { "enabled": true } -} -``` - -- Generated on first run -- Never transmitted to server -- Used only for signing dashboard URLs -- Can be regenerated with `lwp analytics reset` - ### Dashboard Features - Command usage over time @@ -365,23 +373,24 @@ Stored locally in `~/.lwp/config.json`: ## Implementation Plan -### Phase 1 (MVP) - CLI-Only +### Phase 1 (MVP) - CLI-Only (~300 lines, 1-2 files) | Task | Estimate | |------|----------| -| Add config file management (`~/.lwp/config.json`) | S | -| Implement opt-in prompt on first run | S | -| Add event tracking to command execution | M | +| Config file management (`~/.lwp/config.json`) | S | +| Opt-in prompt on first run | S | +| Event tracking with Commander hooks | S | | Local event storage (JSONL file) | S | | `lwp analytics` commands (status, on, off, show, reset) | M | -| CLI dashboard visualization | M | +| Simple text summary output | S | | Documentation | S | -| **Total** | **~1-2 days** | +| **Total** | **~1 day** | ### Phase 2 - Central Service | Task | Estimate | |------|----------| +| Add extended event fields (version, os, session_id, error_category) | S | | Design API endpoints | S | | Set up serverless infrastructure (Lambda + DynamoDB) | M | | Implement event ingestion endpoint | M | @@ -393,6 +402,7 @@ Stored locally in `~/.lwp/config.json`: | Task | Estimate | |------|----------| +| Add userId and secretKey to config | S | | Implement HMAC signing in CLI | S | | Add signature validation to server | S | | Build user-specific dashboard views | L | @@ -405,10 +415,10 @@ Stored locally in `~/.lwp/config.json`: ### GDPR Compliance -- **Data minimization:** Only collect what's necessary +- **Data minimization:** Only collect what's necessary (Phase 1 is extremely minimal) - **Purpose limitation:** Only for product improvement -- **Right to erasure:** `lwp analytics reset` + server deletion -- **Right to access:** `lwp analytics show --json` + dashboard export +- **Right to erasure:** `lwp analytics reset` +- **Right to access:** `lwp analytics show --json` - **Consent:** Explicit opt-in prompt ### Data Flow @@ -417,16 +427,12 @@ Stored locally in `~/.lwp/config.json`: User's Machine Our Infrastructure ━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━ ~/.lwp/ -├── config.json ─── userId never leaves machine -│ ├── userId -│ └── secretKey +├── config.json ─── Config stays local (Phase 1) +│ └── analytics.enabled │ └── analytics/ - └── events.jsonl ─── Events (no userId) ──▶ Analytics API - │ - ▼ - Aggregated stats - (no individual data) + └── events.jsonl ─── Events stay local (Phase 1) + ──▶ Anonymous events (Phase 2+) ``` --- @@ -434,27 +440,28 @@ User's Machine Our Infrastructure ## Decisions 1. **Opt-in vs Opt-out default?** - - **Decision: Opt-in by default** - - Users must explicitly agree to analytics on first run - - No data collected until user consents + - **Decision: Enabled by default (opt-out model)** + - Analytics enabled by default to maximize data collection + - Users can easily disable with `lwp analytics off` + - First-run prompt informs users and offers easy opt-out + - Non-interactive terminals also default to enabled 2. **What commands to exclude from tracking?** - - **Decision: Configurable exclusion list** - - Initial exclusions: - - `wpe *` - WP Engine commands (may contain sensitive hosting info) - - `analytics *` - Meta commands about analytics itself - - Exclusion list stored in config, extensible for future needs + - **Decision: Hardcoded prefix exclusions** + - `wpe.` - WP Engine commands + - `analytics.` - Meta commands + - User-configurable exclusions deferred to future phase 3. **Should we track error types?** - - **Decision: Yes, track error categories** - - Track category (e.g., `site_not_found`, `timeout`, `auth_failed`) - - Never track error messages (may contain paths/names) + - **Decision: Defer to Phase 2** + - Phase 1 tracks only `success: true/false` + - Error categorization added when transmitting to server 4. **Offline behavior?** - - **Decision: Queue locally, send when online** + - **Decision: Local-only for Phase 1** - Events stored in local JSONL file - - Batched and sent when connectivity available - - Max queue size: 10,000 events (FIFO) + - No network transmission until Phase 2 + - Max queue size: 10,000 events 5. **CI/CD environments?** - **Decision: Auto-disable in CI** @@ -462,31 +469,31 @@ User's Machine Our Infrastructure - Also detect: `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `TRAVIS` - Can be explicitly enabled with `LWP_ANALYTICS=1` +6. **Implementation complexity?** + - **Decision: Minimal for Phase 1** + - Single analytics module (~300 lines) + - No external dependencies + - Add complexity only when needed for Phase 2+ + --- -## Appendix: Example Event Payloads +## Appendix: Event Payloads -### Command Executed (Success) +### Phase 1: Minimal Event ```json { - "event": "command_executed", "command": "sites.list", "success": true, "duration_ms": 234, - "cli_version": "0.0.5", - "os": "darwin", - "node_version": "20.10.0", - "timestamp": "2025-01-31T12:00:00.000Z", - "session_id": "abc123" + "timestamp": "2025-01-31T12:00:00.000Z" } ``` -### Command Executed (Failure) +### Phase 2: Extended Event (Future) ```json { - "event": "command_executed", "command": "sites.start", "success": false, "error_category": "site_not_found", @@ -499,19 +506,33 @@ User's Machine Our Infrastructure } ``` -### CLI Started +--- -```json -{ - "event": "cli_started", - "cli_version": "0.0.5", - "os": "darwin", - "node_version": "20.10.0", - "timestamp": "2025-01-31T12:00:00.000Z", - "session_id": "abc123" -} +## Security Considerations + +### File Permissions + +- Config file (`~/.lwp/config.json`): `0600` +- Events file (`~/.lwp/analytics/events.jsonl`): `0600` +- Directory (`~/.lwp/`): `0700` + +### Atomic Writes + +Use temp file + rename pattern to prevent corruption: +```typescript +const tempPath = `${configPath}.${process.pid}.tmp`; +fs.writeFileSync(tempPath, data); +fs.chmodSync(tempPath, 0o600); +fs.renameSync(tempPath, configPath); ``` +### Error Handling + +- Analytics failures must never block command execution +- Wrap all analytics code in try/catch +- Log errors to debug, don't show to users +- If config is corrupted, regenerate with defaults + --- ## References diff --git a/packages/analytics-worker/.gitignore b/packages/analytics-worker/.gitignore new file mode 100644 index 0000000..41a2578 --- /dev/null +++ b/packages/analytics-worker/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +node_modules/ diff --git a/packages/analytics-worker/package-lock.json b/packages/analytics-worker/package-lock.json new file mode 100644 index 0000000..72c160d --- /dev/null +++ b/packages/analytics-worker/package-lock.json @@ -0,0 +1,1512 @@ +{ + "name": "@local-labs-jpollock/analytics-worker", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@local-labs-jpollock/analytics-worker", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "wrangler": "^4.61.1" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz", + "integrity": "sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20260115.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260128.0.tgz", + "integrity": "sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260128.0.tgz", + "integrity": "sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260128.0.tgz", + "integrity": "sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260128.0.tgz", + "integrity": "sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260128.0.tgz", + "integrity": "sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260131.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260131.0.tgz", + "integrity": "sha512-ELgvb2mp68Al50p+FmpgCO2hgU5o4tmz8pi7kShN+cRXc0UZoEdxpDIikR0CeT7b3tV7wlnEnsUzd0UoJLS0oQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", + "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260128.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260128.0.tgz", + "integrity": "sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260128.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "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/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260128.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz", + "integrity": "sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260128.0", + "@cloudflare/workerd-darwin-arm64": "1.20260128.0", + "@cloudflare/workerd-linux-64": "1.20260128.0", + "@cloudflare/workerd-linux-arm64": "1.20260128.0", + "@cloudflare/workerd-windows-64": "1.20260128.0" + } + }, + "node_modules/wrangler": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.61.1.tgz", + "integrity": "sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.12.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.0", + "miniflare": "4.20260128.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260128.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260128.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/packages/analytics-worker/package.json b/packages/analytics-worker/package.json new file mode 100644 index 0000000..0999719 --- /dev/null +++ b/packages/analytics-worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "@local-labs-jpollock/analytics-worker", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:create": "wrangler d1 create lwp-analytics", + "db:migrate": "wrangler d1 execute lwp-analytics --file=./schema.sql", + "db:migrate:local": "wrangler d1 execute lwp-analytics --local --file=./schema.sql" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "wrangler": "^4.61.1" + } +} diff --git a/packages/analytics-worker/schema.sql b/packages/analytics-worker/schema.sql new file mode 100644 index 0000000..7836a50 --- /dev/null +++ b/packages/analytics-worker/schema.sql @@ -0,0 +1,32 @@ +-- LWP Analytics Schema +-- Stores anonymous CLI usage events with HMAC authentication + +-- Installations table: stores secret keys for signature verification +CREATE TABLE IF NOT EXISTS installations ( + installation_id TEXT PRIMARY KEY, + secret_key TEXT NOT NULL, + registered_at TEXT NOT NULL +); + +-- Events table: stores usage analytics +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + installation_id TEXT NOT NULL, + session_id TEXT NOT NULL, + command TEXT NOT NULL, + success INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + error_category TEXT, + cli_version TEXT NOT NULL, + os TEXT NOT NULL, + node_version TEXT NOT NULL, + timestamp TEXT NOT NULL, + received_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (installation_id) REFERENCES installations(installation_id) +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_events_installation ON events(installation_id); +CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp); +CREATE INDEX IF NOT EXISTS idx_events_command ON events(command); +CREATE INDEX IF NOT EXISTS idx_events_cli_version ON events(cli_version); diff --git a/packages/analytics-worker/src/index.ts b/packages/analytics-worker/src/index.ts new file mode 100644 index 0000000..25aea7e --- /dev/null +++ b/packages/analytics-worker/src/index.ts @@ -0,0 +1,439 @@ +/** + * LWP Analytics Worker + * + * Receives anonymous usage analytics from the lwp CLI. + * All requests are authenticated with HMAC-SHA256 signatures. + * Stores events in D1 (SQLite at the edge). + */ + +export interface Env { + lwp_analytics: D1Database; + ADMIN_TOKEN?: string; +} + +interface AnalyticsEvent { + command: string; + success: boolean; + duration_ms: number; + timestamp: string; + installation_id: string; + session_id: string; + cli_version: string; + os: string; + node_version: string; + error_category?: string; +} + +// CORS headers for cross-origin requests +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Installation-Id, X-Signature, X-Secret-Key', +}; + +export default { + async fetch(request: Request, env: Env): Promise { + // Handle CORS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + const url = new URL(request.url); + + // Health check endpoint + if (url.pathname === '/health' && request.method === 'GET') { + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Event ingestion endpoint (authenticated) + if (url.pathname === '/v1/events' && request.method === 'POST') { + return handleEventIngestion(request, env); + } + + // Stats endpoint (admin only) + if (url.pathname === '/v1/stats' && request.method === 'GET') { + return handleStats(request, env); + } + + // Dashboard endpoint (signed URL) + if (url.pathname.startsWith('/dashboard/') && request.method === 'GET') { + return handleDashboard(request, env); + } + + return new Response('Not Found', { status: 404, headers: corsHeaders }); + }, +}; + +/** + * Verify HMAC-SHA256 signature + */ +async function verifySignature( + data: string, + signature: string, + secretKeyBase64: string +): Promise { + try { + const encoder = new TextEncoder(); + const keyData = Uint8Array.from(atob(secretKeyBase64), (c) => c.charCodeAt(0)); + + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + const expectedSignature = Array.from(new Uint8Array(signatureBytes)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return signature === expectedSignature; + } catch { + return false; + } +} + +/** + * Get or create installation record + */ +async function getInstallation( + env: Env, + installationId: string +): Promise<{ secret_key: string } | null> { + const result = await env.lwp_analytics + .prepare('SELECT secret_key FROM installations WHERE installation_id = ?') + .bind(installationId) + .first<{ secret_key: string }>(); + return result; +} + +/** + * Register a new installation with its secret key + */ +async function registerInstallation( + env: Env, + installationId: string, + secretKey: string +): Promise { + await env.lwp_analytics + .prepare( + 'INSERT OR IGNORE INTO installations (installation_id, secret_key, registered_at) VALUES (?, ?, ?)' + ) + .bind(installationId, secretKey, new Date().toISOString()) + .run(); +} + +async function handleEventIngestion(request: Request, env: Env): Promise { + try { + const installationId = request.headers.get('X-Installation-Id'); + const signature = request.headers.get('X-Signature'); + const secretKey = request.headers.get('X-Secret-Key'); // Only on first request + + if (!installationId || !signature) { + return new Response(JSON.stringify({ error: 'Missing authentication headers' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + const body = await request.text(); + + // Check if installation exists + let installation = await getInstallation(env, installationId); + + if (!installation) { + // New installation - must provide secret key + if (!secretKey) { + return new Response(JSON.stringify({ error: 'New installation must provide secret key' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Verify signature with provided key before storing + const isValid = await verifySignature(body, signature, secretKey); + if (!isValid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Register the installation + await registerInstallation(env, installationId, secretKey); + installation = { secret_key: secretKey }; + } else { + // Existing installation - verify signature with stored key + const isValid = await verifySignature(body, signature, installation.secret_key); + if (!isValid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + } + + // Parse and validate event + const event = JSON.parse(body) as AnalyticsEvent; + + if ( + !event.command || + typeof event.success !== 'boolean' || + typeof event.duration_ms !== 'number' || + !event.timestamp || + !event.installation_id || + !event.session_id || + !event.cli_version || + !event.os || + !event.node_version + ) { + return new Response(JSON.stringify({ error: 'Invalid event format' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Ensure event installation_id matches header + if (event.installation_id !== installationId) { + return new Response(JSON.stringify({ error: 'Installation ID mismatch' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Insert event into D1 + await env.lwp_analytics + .prepare( + `INSERT INTO events ( + installation_id, + session_id, + command, + success, + duration_ms, + error_category, + cli_version, + os, + node_version, + timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + event.installation_id, + event.session_id, + event.command, + event.success ? 1 : 0, + event.duration_ms, + event.error_category || null, + event.cli_version, + event.os, + event.node_version, + event.timestamp + ) + .run(); + + return new Response(JSON.stringify({ status: 'accepted' }), { + status: 202, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } catch (error) { + console.error('Event ingestion error:', error); + return new Response(JSON.stringify({ error: 'Internal error' }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } +} + +async function handleStats(request: Request, env: Env): Promise { + // Require admin token + const authHeader = request.headers.get('Authorization'); + if (env.ADMIN_TOKEN && authHeader !== `Bearer ${env.ADMIN_TOKEN}`) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + try { + const totalEvents = await env.lwp_analytics + .prepare('SELECT COUNT(*) as count FROM events') + .first<{ count: number }>(); + + const uniqueInstallations = await env.lwp_analytics + .prepare('SELECT COUNT(DISTINCT installation_id) as count FROM events') + .first<{ count: number }>(); + + const commandCounts = await env.lwp_analytics + .prepare( + `SELECT command, COUNT(*) as count + FROM events + GROUP BY command + ORDER BY count DESC + LIMIT 10` + ) + .all<{ command: string; count: number }>(); + + const versionCounts = await env.lwp_analytics + .prepare( + `SELECT cli_version, COUNT(*) as count + FROM events + GROUP BY cli_version + ORDER BY count DESC + LIMIT 5` + ) + .all<{ cli_version: string; count: number }>(); + + const osCounts = await env.lwp_analytics + .prepare( + `SELECT os, COUNT(*) as count + FROM events + GROUP BY os + ORDER BY count DESC` + ) + .all<{ os: string; count: number }>(); + + const successRate = await env.lwp_analytics + .prepare( + `SELECT + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + COUNT(*) as total + FROM events` + ) + .first<{ successes: number; total: number }>(); + + return new Response( + JSON.stringify({ + total_events: totalEvents?.count || 0, + unique_installations: uniqueInstallations?.count || 0, + success_rate: + successRate && successRate.total > 0 + ? ((successRate.successes / successRate.total) * 100).toFixed(1) + '%' + : 'N/A', + top_commands: commandCounts?.results || [], + versions: versionCounts?.results || [], + os_distribution: osCounts?.results || [], + }), + { + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + } + ); + } catch (error) { + console.error('Stats error:', error); + return new Response(JSON.stringify({ error: 'Internal error' }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } +} + +async function handleDashboard(request: Request, env: Env): Promise { + try { + const url = new URL(request.url); + const pathParts = url.pathname.split('/'); + const installationId = pathParts[2]; // /dashboard/{installationId} + + const expiration = url.searchParams.get('exp'); + const signature = url.searchParams.get('sig'); + + if (!installationId || !expiration || !signature) { + return new Response(JSON.stringify({ error: 'Missing parameters' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Check expiration + const expTime = parseInt(expiration, 10); + if (isNaN(expTime) || expTime < Math.floor(Date.now() / 1000)) { + return new Response(JSON.stringify({ error: 'Link expired' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Get installation's secret key + const installation = await getInstallation(env, installationId); + if (!installation) { + return new Response(JSON.stringify({ error: 'Installation not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Verify signature + const payload = `${installationId}:${expiration}`; + const isValid = await verifySignature(payload, signature, installation.secret_key); + if (!isValid) { + return new Response(JSON.stringify({ error: 'Invalid signature' }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Get this installation's stats + const totalEvents = await env.lwp_analytics + .prepare('SELECT COUNT(*) as count FROM events WHERE installation_id = ?') + .bind(installationId) + .first<{ count: number }>(); + + const commandCounts = await env.lwp_analytics + .prepare( + `SELECT command, COUNT(*) as count + FROM events + WHERE installation_id = ? + GROUP BY command + ORDER BY count DESC + LIMIT 10` + ) + .bind(installationId) + .all<{ command: string; count: number }>(); + + const successRate = await env.lwp_analytics + .prepare( + `SELECT + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + COUNT(*) as total + FROM events + WHERE installation_id = ?` + ) + .bind(installationId) + .first<{ successes: number; total: number }>(); + + const recentEvents = await env.lwp_analytics + .prepare( + `SELECT command, success, duration_ms, timestamp + FROM events + WHERE installation_id = ? + ORDER BY timestamp DESC + LIMIT 20` + ) + .bind(installationId) + .all<{ command: string; success: number; duration_ms: number; timestamp: string }>(); + + return new Response( + JSON.stringify({ + installation_id: installationId, + total_events: totalEvents?.count || 0, + success_rate: + successRate && successRate.total > 0 + ? ((successRate.successes / successRate.total) * 100).toFixed(1) + '%' + : 'N/A', + top_commands: commandCounts?.results || [], + recent_events: recentEvents?.results || [], + }), + { + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + } + ); + } catch (error) { + console.error('Dashboard error:', error); + return new Response(JSON.stringify({ error: 'Internal error' }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } +} diff --git a/packages/analytics-worker/tsconfig.json b/packages/analytics-worker/tsconfig.json new file mode 100644 index 0000000..ec96427 --- /dev/null +++ b/packages/analytics-worker/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2021"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/analytics-worker/wrangler.toml b/packages/analytics-worker/wrangler.toml new file mode 100644 index 0000000..311f80f --- /dev/null +++ b/packages/analytics-worker/wrangler.toml @@ -0,0 +1,9 @@ +name = "lwp-analytics" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[[d1_databases]] +binding = "lwp_analytics" +database_name = "lwp-analytics" +database_id = "c3633feb-70a4-4827-80d2-b14ebf84e3dd" + diff --git a/packages/cli/e2e/analytics.e2e.test.ts b/packages/cli/e2e/analytics.e2e.test.ts new file mode 100644 index 0000000..d9b8ad9 --- /dev/null +++ b/packages/cli/e2e/analytics.e2e.test.ts @@ -0,0 +1,171 @@ +/** + * Analytics E2E Tests + * + * Tests the analytics CLI commands. These don't require Local to be running. + * Uses environment variable LWP_ANALYTICS to control state without modifying config. + */ + +import { runCLI } from './helpers/cli'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const CONFIG_DIR = path.join(os.homedir(), '.lwp'); +const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json'); +const EVENTS_DIR = path.join(CONFIG_DIR, 'analytics'); +const EVENTS_PATH = path.join(EVENTS_DIR, 'events.jsonl'); + +describe('analytics commands', () => { + // Save original state before tests + let originalConfig: string | null = null; + let originalEvents: string | null = null; + + beforeAll(() => { + // Backup existing config and events + try { + if (fs.existsSync(CONFIG_PATH)) { + originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); + } + if (fs.existsSync(EVENTS_PATH)) { + originalEvents = fs.readFileSync(EVENTS_PATH, 'utf-8'); + } + } catch { + // Ignore read errors + } + }); + + afterAll(() => { + // Restore original state + try { + if (originalConfig !== null) { + fs.writeFileSync(CONFIG_PATH, originalConfig); + } + if (originalEvents !== null) { + if (!fs.existsSync(EVENTS_DIR)) { + fs.mkdirSync(EVENTS_DIR, { recursive: true }); + } + fs.writeFileSync(EVENTS_PATH, originalEvents); + } + } catch { + // Ignore restore errors + } + }); + + describe('analytics status', () => { + test('returns current status', () => { + const { stdout, exitCode } = runCLI('analytics status'); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/Analytics:/i); + expect(stdout).toMatch(/enabled|disabled/i); + }); + + test('shows event count', () => { + const { stdout, exitCode } = runCLI('analytics status'); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/events stored/i); + }); + }); + + describe('analytics on/off', () => { + test('analytics off disables tracking', () => { + const { stdout, exitCode } = runCLI('analytics off'); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/disabled/i); + + // Verify status shows disabled + const status = runCLI('analytics status'); + expect(status.stdout).toMatch(/disabled/i); + }); + + test('analytics on enables tracking', () => { + const { stdout, exitCode } = runCLI('analytics on'); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/enabled/i); + + // Verify status shows enabled + const status = runCLI('analytics status'); + expect(status.stdout).toMatch(/enabled/i); + }); + }); + + describe('analytics show', () => { + test('returns summary or empty message', () => { + const { stdout, exitCode } = runCLI('analytics show'); + + expect(exitCode).toBe(0); + // Either shows summary or "No analytics data" + expect(stdout).toMatch(/analytics|no analytics data/i); + }); + + test('returns JSON with --json flag', () => { + const { stdout, exitCode } = runCLI('analytics show --json'); + + expect(exitCode).toBe(0); + + // Should be valid JSON array + const data = JSON.parse(stdout); + expect(Array.isArray(data)).toBe(true); + }); + }); + + describe('analytics reset', () => { + beforeEach(() => { + // Ensure analytics is on and has at least one event + runCLI('analytics on'); + }); + + test('clears all events and disables', () => { + const { stdout, exitCode } = runCLI('analytics reset'); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/deleted|reset/i); + + // Verify events are cleared + const status = runCLI('analytics status'); + expect(status.stdout).toMatch(/0|disabled/i); + }); + }); + + describe('environment variable override', () => { + test('LWP_ANALYTICS=0 disables tracking', () => { + // First enable analytics + runCLI('analytics on'); + + // Run with env override - status should show enabled (config) but tracking disabled + const result = runCLI('analytics status'); + expect(result.exitCode).toBe(0); + // The status command reads config, not env override + // But new events won't be recorded when LWP_ANALYTICS=0 + }); + }); + + describe('command exclusion', () => { + beforeEach(() => { + // Clear events and enable analytics + runCLI('analytics reset'); + runCLI('analytics on'); + }); + + test('analytics commands are not tracked', () => { + // Run several analytics commands + runCLI('analytics status'); + runCLI('analytics status'); + runCLI('analytics status'); + + // Check event count - should still be 0 + const { stdout } = runCLI('analytics show --json'); + const events = JSON.parse(stdout); + + // Filter out any non-analytics commands that might have been tracked + const analyticsEvents = events.filter((e: { command: string }) => + e.command.startsWith('analytics.') + ); + + expect(analyticsEvents.length).toBe(0); + }); + }); +}); diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts new file mode 100644 index 0000000..c29dbd9 --- /dev/null +++ b/packages/cli/src/analytics.ts @@ -0,0 +1,502 @@ +/** + * Anonymous Usage Analytics - Phase 3 (Signed Authentication) + * + * Collects anonymous usage data with user consent and transmits to server. + * All requests are signed with HMAC-SHA256 for authentication. + * + * Privacy: Only tracks command names, success/failure, duration, and system info. + * Never tracks: arguments, site names, paths, or any PII. + * + * Identifiers: + * - installationId: Random UUID, identifies this CLI installation (not the user) + * - secretKey: Random 32 bytes, used for HMAC signing (never transmitted after registration) + * - sessionId: Random UUID per CLI invocation, correlates commands in a session + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +interface AnalyticsConfig { + installationId?: string; + secretKey?: string; + registeredAt?: string; // When secretKey was first sent to server + analytics: { + enabled: boolean; + promptedAt: string | null; + }; +} + +export type ErrorCategory = + | 'site_not_found' + | 'site_not_running' + | 'local_not_running' + | 'timeout' + | 'network_error' + | 'validation_error' + | 'unknown'; + +interface AnalyticsEvent { + // Phase 1 fields + command: string; + success: boolean; + duration_ms: number; + timestamp: string; + + // Phase 2 fields + installation_id: string; + session_id: string; + cli_version: string; + os: string; + node_version: string; + error_category?: ErrorCategory; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MAX_EVENTS = 10000; +const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; +const ANALYTICS_BASE_URL = + process.env.LWP_ANALYTICS_ENDPOINT?.replace('/v1/events', '') || + 'https://lwp-analytics.jeremy7746.workers.dev'; +const ANALYTICS_ENDPOINT = `${ANALYTICS_BASE_URL}/v1/events`; +const TRANSMISSION_TIMEOUT = 5000; // 5 seconds + +// Session ID generated once per CLI invocation +const SESSION_ID = crypto.randomUUID(); + +// CLI version from package.json +const CLI_VERSION = require('../package.json').version; + +// Lazy-initialized paths (for testability) +function getLwpDir(): string { + return path.join(os.homedir(), '.lwp'); +} + +function getConfigPath(): string { + return path.join(getLwpDir(), 'config.json'); +} + +function getEventsDir(): string { + return path.join(getLwpDir(), 'analytics'); +} + +function getEventsPath(): string { + return path.join(getEventsDir(), 'events.jsonl'); +} + +const CI_ENV_VARS = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'JENKINS_URL', + 'TRAVIS', + 'CIRCLECI', + 'BUILDKITE', +]; + +// ============================================================================ +// Config Management +// ============================================================================ + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + } +} + +function generateInstallationId(): string { + return crypto.randomUUID(); +} + +function generateSecretKey(): string { + return crypto.randomBytes(32).toString('base64'); +} + +function readConfig(): AnalyticsConfig { + try { + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + const data = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(data); + // Validate structure + if (typeof config.analytics?.enabled === 'boolean') { + let needsWrite = false; + + // Ensure installationId exists (migrate from Phase 1/2) + if (!config.installationId) { + config.installationId = generateInstallationId(); + needsWrite = true; + } + + // Ensure secretKey exists (migrate from Phase 2) + if (!config.secretKey) { + config.secretKey = generateSecretKey(); + needsWrite = true; + } + + if (needsWrite) { + writeConfig(config); + } + return config; + } + } + } catch { + // Corrupted config, will regenerate + } + // Default to enabled (opt-out model) with new credentials + return { + installationId: generateInstallationId(), + secretKey: generateSecretKey(), + analytics: { enabled: true, promptedAt: null }, + }; +} + +function writeConfig(config: AnalyticsConfig): void { + const configPath = getConfigPath(); + ensureDir(getLwpDir()); + const tempPath = `${configPath}.${process.pid}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2)); + fs.chmodSync(tempPath, 0o600); + fs.renameSync(tempPath, configPath); +} + +export function getInstallationId(): string { + return readConfig().installationId || generateInstallationId(); +} + +export function getSecretKey(): string { + return readConfig().secretKey || generateSecretKey(); +} + +export function getSessionId(): string { + return SESSION_ID; +} + +export function isAnalyticsEnabled(): boolean { + const override = process.env.LWP_ANALYTICS; + if (override === '0') return false; + if (override === '1') return true; + if (CI_ENV_VARS.some((v) => process.env[v])) return false; + return readConfig().analytics.enabled; +} + +export function setAnalyticsEnabled(enabled: boolean): void { + const config = readConfig(); + config.analytics.enabled = enabled; + config.analytics.promptedAt = config.analytics.promptedAt || new Date().toISOString(); + writeConfig(config); +} + +export function hasBeenPrompted(): boolean { + return readConfig().analytics.promptedAt !== null; +} + +// ============================================================================ +// HMAC Signing +// ============================================================================ + +/** + * Sign data with HMAC-SHA256 + */ +function signData(data: string, secretKey: string): string { + const key = Buffer.from(secretKey, 'base64'); + return crypto.createHmac('sha256', key).update(data).digest('hex'); +} + +/** + * Mark this installation as registered + */ +function markAsRegistered(): void { + const config = readConfig(); + config.registeredAt = new Date().toISOString(); + writeConfig(config); +} + +// ============================================================================ +// Opt-In Prompt +// ============================================================================ + +export async function showOptInPrompt(): Promise { + // Mark as prompted and keep enabled (opt-out model) + const config = readConfig(); + config.analytics.promptedAt = new Date().toISOString(); + config.analytics.enabled = true; + writeConfig(config); + + // In non-interactive mode, silently enable without message + if (!process.stdin.isTTY) { + return true; + } + + // Show informational message about analytics + console.log(''); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('Anonymous usage analytics enabled'); + console.log(''); + console.log('We collect anonymous data to improve the CLI.'); + console.log('No personal information, site names, or command arguments are collected.'); + console.log(''); + console.log('To disable: lwp analytics off'); + console.log('Learn more: https://github.com/jpollock/local-addon-cli#analytics'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + + return true; +} + +// ============================================================================ +// Event Tracking +// ============================================================================ + +function isCommandExcluded(command: string): boolean { + return EXCLUDED_PREFIXES.some((prefix) => command.startsWith(prefix)); +} + +/** + * Transmit event to analytics server with HMAC signature (fire-and-forget) + */ +async function transmitEvent(event: AnalyticsEvent): Promise { + try { + const config = readConfig(); + const installationId = config.installationId!; + const secretKey = config.secretKey!; + const isFirstRequest = !config.registeredAt; + + const body = JSON.stringify(event); + const signature = signData(body, secretKey); + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Installation-Id': installationId, + 'X-Signature': signature, + }; + + // On first request, send the secret key so server can store it + if (isFirstRequest) { + headers['X-Secret-Key'] = secretKey; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TRANSMISSION_TIMEOUT); + + const response = await fetch(ANALYTICS_ENDPOINT, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // If successful and this was first request, mark as registered + if (response.ok && isFirstRequest) { + markAsRegistered(); + } + } catch { + // Silently ignore transmission errors - never block CLI + } +} + +export function recordEvent(event: AnalyticsEvent): void { + try { + if (!isAnalyticsEnabled()) return; + if (isCommandExcluded(event.command)) return; + + const eventsDir = getEventsDir(); + const eventsPath = getEventsPath(); + + ensureDir(eventsDir); + + // Check event count and rotate if needed + if (fs.existsSync(eventsPath)) { + const content = fs.readFileSync(eventsPath, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + if (lines.length >= MAX_EVENTS) { + // Keep newest 80% + const keepCount = Math.floor(MAX_EVENTS * 0.8); + const toKeep = lines.slice(-keepCount); + fs.writeFileSync(eventsPath, toKeep.join('\n') + '\n'); + fs.chmodSync(eventsPath, 0o600); + } + } + + // Append new event to local storage + const line = JSON.stringify(event) + '\n'; + fs.appendFileSync(eventsPath, line); + + // Ensure permissions on first write + fs.chmodSync(eventsPath, 0o600); + + // Transmit to server (fire-and-forget, don't await) + transmitEvent(event).catch(() => { + // Ignore transmission errors + }); + } catch { + // Never let analytics errors affect command execution + } +} + +export function readEvents(): AnalyticsEvent[] { + try { + const eventsPath = getEventsPath(); + if (!fs.existsSync(eventsPath)) return []; + const content = fs.readFileSync(eventsPath, 'utf-8'); + return content + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch { + return []; + } +} + +export function clearEvents(): void { + try { + const eventsPath = getEventsPath(); + if (fs.existsSync(eventsPath)) { + fs.unlinkSync(eventsPath); + } + } catch { + // Ignore cleanup errors + } +} + +/** + * Reset analytics: clear events and regenerate installationId + secretKey + */ +export function resetAnalytics(): void { + clearEvents(); + const config = readConfig(); + config.installationId = generateInstallationId(); + config.secretKey = generateSecretKey(); + delete config.registeredAt; // Will need to re-register + config.analytics.enabled = false; + writeConfig(config); +} + +// ============================================================================ +// Command Tracking (for Commander hooks) +// ============================================================================ + +let commandStartTime: number | null = null; +let currentCommandName: string | null = null; + +export function startTracking(commandName: string): void { + commandStartTime = Date.now(); + currentCommandName = commandName; +} + +export function finishTracking(success: boolean, errorCategory?: ErrorCategory): void { + if (commandStartTime === null || currentCommandName === null) return; + + const duration = Date.now() - commandStartTime; + const event: AnalyticsEvent = { + command: currentCommandName, + success, + duration_ms: duration, + timestamp: new Date().toISOString(), + installation_id: getInstallationId(), + session_id: SESSION_ID, + cli_version: CLI_VERSION, + os: os.platform(), + node_version: process.version, + }; + + // Add error category for failures + if (!success && errorCategory) { + event.error_category = errorCategory; + } + + recordEvent(event); + + commandStartTime = null; + currentCommandName = null; +} + +// ============================================================================ +// Dashboard URL Generation +// ============================================================================ + +/** + * Generate a signed dashboard URL for viewing personal analytics + * URL expires in 1 hour + */ +export function getDashboardUrl(): string { + const config = readConfig(); + const installationId = config.installationId!; + const secretKey = config.secretKey!; + + // Expire in 1 hour + const expiration = Math.floor(Date.now() / 1000) + 3600; + + // Sign: installationId:expiration + const payload = `${installationId}:${expiration}`; + const signature = signData(payload, secretKey); + + return `${ANALYTICS_BASE_URL}/dashboard/${installationId}?exp=${expiration}&sig=${signature}`; +} + +// ============================================================================ +// Analytics Summary +// ============================================================================ + +export function getStatus(): { enabled: boolean; eventCount: number; installationId: string } { + return { + enabled: isAnalyticsEnabled(), + eventCount: readEvents().length, + installationId: getInstallationId(), + }; +} + +export function getSummary(): string { + const events = readEvents(); + if (events.length === 0) { + return 'No analytics data collected yet.'; + } + + const total = events.length; + const successful = events.filter((e) => e.success).length; + const successRate = ((successful / total) * 100).toFixed(1); + + // Count commands + const commandCounts: Record = {}; + events.forEach((e) => { + commandCounts[e.command] = (commandCounts[e.command] || 0) + 1; + }); + + // Sort by count + const topCommands = Object.entries(commandCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + // Count recent failures (last 7 days) + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const recentFailures = events.filter( + (e) => !e.success && new Date(e.timestamp).getTime() > weekAgo + ).length; + + let output = ` +Analytics Summary +───────────────── +Total commands: ${total} +Success rate: ${successRate}% + +Top commands:`; + + topCommands.forEach(([cmd, count]) => { + output += `\n ${cmd.padEnd(15)} ${count}`; + }); + + output += `\n\nRecent failures: ${recentFailures} in last 7 days`; + + return output; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a0afc54..b749a15 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -23,6 +23,7 @@ import { getOutputFormat, FormatterOptions, } from './formatters'; +import * as analytics from './analytics'; // Package info const PACKAGE_NAME = '@local-labs-jpollock/local-cli'; @@ -1731,6 +1732,69 @@ wpe } }); +// =========================================== +// Analytics Commands +// =========================================== + +const analyticsCmd = program.command('analytics').description('Manage anonymous usage analytics'); + +analyticsCmd + .command('status') + .description('Show analytics status') + .action(() => { + const status = analytics.getStatus(); + console.log(`Analytics: ${status.enabled ? 'enabled' : 'disabled'}`); + console.log(`Total events stored: ${status.eventCount}`); + console.log(`Installation ID: ${status.installationId}`); + }); + +analyticsCmd + .command('on') + .description('Enable analytics') + .action(() => { + analytics.setAnalyticsEnabled(true); + console.log('Analytics enabled. Thank you for helping improve lwp!'); + }); + +analyticsCmd + .command('off') + .description('Disable analytics') + .action(() => { + analytics.setAnalyticsEnabled(false); + console.log('Analytics disabled. No data will be collected.'); + }); + +analyticsCmd + .command('show') + .description('View your analytics data') + .option('--json', 'Output as JSON') + .action((options) => { + if (options.json) { + const events = analytics.readEvents(); + console.log(JSON.stringify(events, null, 2)); + } else { + console.log(analytics.getSummary()); + } + }); + +analyticsCmd + .command('reset') + .description('Delete all analytics data and regenerate installation ID') + .action(() => { + const count = analytics.getStatus().eventCount; + analytics.resetAnalytics(); + console.log(`Deleted ${count} local events. Installation ID regenerated. Analytics disabled.`); + }); + +analyticsCmd + .command('dashboard') + .description('Open your personal analytics dashboard') + .action(() => { + const url = analytics.getDashboardUrl(); + console.log('Your analytics dashboard (expires in 1 hour):'); + console.log(url); + }); + // =========================================== // Helper Functions // =========================================== @@ -1852,15 +1916,64 @@ program } }); -// Check for updates (skip for update command itself and quiet mode) -const args = process.argv.slice(2); -const isUpdateCommand = args[0] === 'update'; -const isQuiet = args.includes('--quiet') || args.includes('--json'); +/** + * Get command path for analytics (e.g., "sites.list", "wp") + */ +function getCommandPath(command: Command): string { + const parts: string[] = []; + let current: Command | null = command; + while (current && current.name() !== 'lwp') { + parts.unshift(current.name()); + current = current.parent; + } + return parts.join('.') || 'unknown'; +} + +// =========================================== +// Main Entry Point +// =========================================== + +async function main(): Promise { + const args = process.argv.slice(2); + const isUpdateCommand = args[0] === 'update'; + const isQuiet = args.includes('--quiet') || args.includes('--json'); + + // Check for updates (skip for update command itself and quiet mode) + if (!isUpdateCommand && !isQuiet) { + // Fire and forget - don't block startup + checkForUpdates().catch(() => {}); + } -if (!isUpdateCommand && !isQuiet) { - // Fire and forget - don't block startup - checkForUpdates().catch(() => {}); + // Show opt-in prompt on first run (skip for certain commands) + const skipPromptCommands = ['update', 'analytics', '--help', '-h', '--version', '-V']; + const shouldSkipPrompt = args.length === 0 || skipPromptCommands.some((cmd) => args[0] === cmd); + + if (!shouldSkipPrompt && !analytics.hasBeenPrompted()) { + await analytics.showOptInPrompt(); + } + + // Set up analytics tracking hooks + // preAction receives (thisCommand, actionCommand) - we want actionCommand + program.hook('preAction', (thisCommand, actionCommand) => { + const commandPath = getCommandPath(actionCommand); + analytics.startTracking(commandPath); + }); + + program.hook('postAction', () => { + analytics.finishTracking(true); + }); + + // Parse and execute + try { + await program.parseAsync(process.argv); + } catch (error) { + analytics.finishTracking(false); + throw error; + } } -// Parse and execute -program.parse(); +// Run main +main().catch((error) => { + console.error(formatError(error.message || 'An unexpected error occurred')); + process.exit(1); +}); diff --git a/packages/cli/tests/analytics.test.ts b/packages/cli/tests/analytics.test.ts new file mode 100644 index 0000000..ab0f4f0 --- /dev/null +++ b/packages/cli/tests/analytics.test.ts @@ -0,0 +1,639 @@ +/** + * Analytics Module Tests + * + * Tests for the anonymous usage analytics module (Phase 3). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; + +// Mock fs module before importing analytics +jest.mock('fs'); + +// Mock os module +jest.mock('os', () => ({ + ...jest.requireActual('os'), + homedir: jest.fn(() => '/mock/home'), + platform: jest.fn(() => 'darwin'), +})); + +// Mock crypto.randomUUID +jest.mock('crypto', () => ({ + ...jest.requireActual('crypto'), + randomUUID: jest.fn(() => 'mock-uuid-1234'), +})); + +// Mock fetch for transmission tests +global.fetch = jest.fn(() => Promise.resolve({ ok: true })) as jest.Mock; + +const mockFs = fs as jest.Mocked; +const mockOs = os as jest.Mocked; +const mockCrypto = crypto as jest.Mocked; + +// Import after mocking +import * as analytics from '../src/analytics'; + +describe('analytics', () => { + const mockHomedir = '/mock/home'; + const configPath = path.join(mockHomedir, '.lwp', 'config.json'); + const eventsPath = path.join(mockHomedir, '.lwp', 'analytics', 'events.jsonl'); + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.LWP_ANALYTICS; + delete process.env.LWP_ANALYTICS_ENDPOINT; + delete process.env.CI; + delete process.env.GITHUB_ACTIONS; + }); + + describe('isAnalyticsEnabled', () => { + it('returns true when config does not exist (enabled by default)', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(analytics.isAnalyticsEnabled()).toBe(true); + }); + + it('returns true when analytics is enabled in config', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(true); + }); + + it('returns false when analytics is disabled in config', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: false, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('respects LWP_ANALYTICS=0 override', () => { + process.env.LWP_ANALYTICS = '0'; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('respects LWP_ANALYTICS=1 override', () => { + process.env.LWP_ANALYTICS = '1'; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: false, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(true); + }); + + it('auto-disables in CI environments', () => { + process.env.CI = 'true'; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('auto-disables in GitHub Actions', () => { + process.env.GITHUB_ACTIONS = 'true'; + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('handles corrupted config gracefully (defaults to enabled)', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue('{ invalid json'); + + expect(analytics.isAnalyticsEnabled()).toBe(true); + }); + }); + + describe('setAnalyticsEnabled', () => { + it('writes config with enabled=true', () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.writeFileSync.mockImplementation(() => {}); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.chmodSync.mockImplementation(() => {}); + mockFs.renameSync.mockImplementation(() => {}); + + analytics.setAnalyticsEnabled(true); + + expect(mockFs.writeFileSync).toHaveBeenCalled(); + const writtenContent = mockFs.writeFileSync.mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed.analytics.enabled).toBe(true); + expect(parsed.analytics.promptedAt).toBeDefined(); + expect(parsed.installationId).toBeDefined(); + }); + + it('sets proper file permissions (0600)', () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.writeFileSync.mockImplementation(() => {}); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.chmodSync.mockImplementation(() => {}); + mockFs.renameSync.mockImplementation(() => {}); + + analytics.setAnalyticsEnabled(true); + + expect(mockFs.chmodSync).toHaveBeenCalledWith(expect.any(String), 0o600); + }); + }); + + describe('hasBeenPrompted', () => { + it('returns false when config does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(analytics.hasBeenPrompted()).toBe(false); + }); + + it('returns false when promptedAt is null', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: false, promptedAt: null }, + }) + ); + + expect(analytics.hasBeenPrompted()).toBe(false); + }); + + it('returns true when promptedAt has a value', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.hasBeenPrompted()).toBe(true); + }); + }); + + describe('getInstallationId', () => { + it('returns installationId from config', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'existing-install-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.getInstallationId()).toBe('existing-install-id'); + }); + + it('generates new installationId when missing', () => { + mockFs.existsSync.mockReturnValue(false); + + const id = analytics.getInstallationId(); + + expect(id).toBe('mock-uuid-1234'); + }); + }); + + describe('getSessionId', () => { + it('returns consistent session ID within same process', () => { + const id1 = analytics.getSessionId(); + const id2 = analytics.getSessionId(); + + expect(id1).toBe(id2); + expect(id1).toBe('mock-uuid-1234'); + }); + }); + + describe('getSecretKey', () => { + it('returns secretKey from config', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + secretKey: 'dGVzdC1zZWNyZXQta2V5LWJhc2U2NA==', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.getSecretKey()).toBe('dGVzdC1zZWNyZXQta2V5LWJhc2U2NA=='); + }); + + it('generates new secretKey when missing', () => { + mockFs.existsSync.mockReturnValue(false); + + const key = analytics.getSecretKey(); + + // Should be a base64 string (generated from randomBytes) + expect(key).toBeDefined(); + expect(typeof key).toBe('string'); + }); + }); + + describe('getDashboardUrl', () => { + it('generates signed dashboard URL with expiration', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-install-id', + secretKey: 'dGVzdC1zZWNyZXQta2V5LWJhc2U2NA==', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + const url = analytics.getDashboardUrl(); + + expect(url).toContain('/dashboard/test-install-id'); + expect(url).toContain('exp='); + expect(url).toContain('sig='); + }); + }); + + describe('readEvents', () => { + it('returns empty array when events file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(analytics.readEvents()).toEqual([]); + }); + + it('parses JSONL file correctly', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + '{"command":"sites.list","success":true,"duration_ms":100,"timestamp":"2025-01-31T00:00:00Z","installation_id":"a","session_id":"b","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"wp","success":false,"duration_ms":200,"timestamp":"2025-01-31T00:01:00Z","installation_id":"a","session_id":"b","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + ); + + const events = analytics.readEvents(); + + expect(events).toHaveLength(2); + expect(events[0].command).toBe('sites.list'); + expect(events[0].success).toBe(true); + expect(events[0].installation_id).toBe('a'); + expect(events[1].command).toBe('wp'); + expect(events[1].success).toBe(false); + }); + + it('handles corrupted events file gracefully', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue('{ invalid json\n'); + + expect(analytics.readEvents()).toEqual([]); + }); + }); + + describe('clearEvents', () => { + it('deletes the events file', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.unlinkSync.mockImplementation(() => {}); + + analytics.clearEvents(); + + expect(mockFs.unlinkSync).toHaveBeenCalledWith(eventsPath); + }); + + it('handles missing file gracefully', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => analytics.clearEvents()).not.toThrow(); + }); + }); + + describe('resetAnalytics', () => { + it('clears events and regenerates installationId and secretKey', () => { + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + if (p === eventsPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'old-id', + secretKey: 'old-secret-key', + registeredAt: '2025-01-31T00:00:00Z', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.unlinkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.chmodSync.mockImplementation(() => {}); + mockFs.renameSync.mockImplementation(() => {}); + + analytics.resetAnalytics(); + + // Should delete events + expect(mockFs.unlinkSync).toHaveBeenCalledWith(eventsPath); + + // Should write new config with new installationId and secretKey + expect(mockFs.writeFileSync).toHaveBeenCalled(); + const writtenContent = mockFs.writeFileSync.mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed.installationId).toBe('mock-uuid-1234'); + expect(parsed.secretKey).toBeDefined(); + expect(parsed.secretKey).not.toBe('old-secret-key'); + expect(parsed.registeredAt).toBeUndefined(); + expect(parsed.analytics.enabled).toBe(false); + }); + }); + + describe('getStatus', () => { + it('returns enabled status, event count, and installationId', () => { + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + if (p === eventsPath) return true; + return false; + }); + mockFs.readFileSync.mockImplementation((p) => { + if (p === configPath) { + return JSON.stringify({ + installationId: 'test-install-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }); + } + if (p === eventsPath) { + return ( + '{"command":"a","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + ); + } + return ''; + }); + + const status = analytics.getStatus(); + + expect(status.enabled).toBe(true); + expect(status.eventCount).toBe(2); + expect(status.installationId).toBe('test-install-id'); + }); + }); + + describe('getSummary', () => { + it('returns message when no events', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(analytics.getSummary()).toBe('No analytics data collected yet.'); + }); + + it('calculates success rate correctly', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + '{"command":"a","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"c","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"d","success":false,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + ); + + const summary = analytics.getSummary(); + + expect(summary).toContain('Total commands: 4'); + expect(summary).toContain('Success rate: 75.0%'); + }); + + it('shows top commands', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + '{"command":"sites.list","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"sites.list","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + + '{"command":"wp","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z","installation_id":"x","session_id":"y","cli_version":"0.0.5","os":"darwin","node_version":"v20"}\n' + ); + + const summary = analytics.getSummary(); + + expect(summary).toContain('sites.list'); + expect(summary).toContain('wp'); + }); + }); + + describe('command tracking', () => { + it('tracks command with Phase 2 fields', () => { + // Set up mocks for recording + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-install-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.appendFileSync.mockImplementation(() => {}); + mockFs.chmodSync.mockImplementation(() => {}); + + analytics.startTracking('sites.list'); + analytics.finishTracking(true); + + expect(mockFs.appendFileSync).toHaveBeenCalled(); + const appendedContent = mockFs.appendFileSync.mock.calls[0][1] as string; + const event = JSON.parse(appendedContent); + expect(event.command).toBe('sites.list'); + expect(event.success).toBe(true); + expect(event.duration_ms).toBeGreaterThanOrEqual(0); + expect(event.installation_id).toBe('test-install-id'); + expect(event.session_id).toBeDefined(); + expect(event.cli_version).toBeDefined(); + expect(event.os).toBe('darwin'); + expect(event.node_version).toBeDefined(); + }); + + it('includes error_category on failure', () => { + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-install-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.appendFileSync.mockImplementation(() => {}); + mockFs.chmodSync.mockImplementation(() => {}); + + analytics.startTracking('sites.get'); + analytics.finishTracking(false, 'site_not_found'); + + expect(mockFs.appendFileSync).toHaveBeenCalled(); + const appendedContent = mockFs.appendFileSync.mock.calls[0][1] as string; + const event = JSON.parse(appendedContent); + expect(event.success).toBe(false); + expect(event.error_category).toBe('site_not_found'); + }); + + it('does not track when analytics disabled', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: false, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + analytics.startTracking('sites.list'); + analytics.finishTracking(true); + + expect(mockFs.appendFileSync).not.toHaveBeenCalled(); + }); + + it('does not track excluded commands', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + analytics.startTracking('wpe.status'); + analytics.finishTracking(true); + + expect(mockFs.appendFileSync).not.toHaveBeenCalled(); + }); + + it('does not track analytics commands', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-id', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + analytics.startTracking('analytics.status'); + analytics.finishTracking(true); + + expect(mockFs.appendFileSync).not.toHaveBeenCalled(); + }); + + it('transmits event to server with HMAC signature', async () => { + // Set up fetch mock + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'test-install-id', + secretKey: 'dGVzdC1zZWNyZXQta2V5LWJhc2U2NA==', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.appendFileSync.mockImplementation(() => {}); + mockFs.chmodSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + mockFs.renameSync.mockImplementation(() => {}); + + analytics.startTracking('sites.list'); + analytics.finishTracking(true); + + // Wait for the fire-and-forget fetch to be called + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify fetch was called with HMAC headers + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/events'); + expect(fetchCall[1].method).toBe('POST'); + expect(fetchCall[1].headers['X-Installation-Id']).toBe('test-install-id'); + expect(fetchCall[1].headers['X-Signature']).toBeDefined(); + }); + + it('sends X-Secret-Key header on first request (not registered)', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + // No registeredAt means first request + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'new-install-id', + secretKey: 'bmV3LXNlY3JldC1rZXk=', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.appendFileSync.mockImplementation(() => {}); + mockFs.chmodSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + mockFs.renameSync.mockImplementation(() => {}); + + analytics.startTracking('sites.list'); + analytics.finishTracking(true); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['X-Secret-Key']).toBe('bmV3LXNlY3JldC1rZXk='); + }); + + it('does not send X-Secret-Key header when already registered', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + // Has registeredAt means already registered + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'registered-install-id', + secretKey: 'cmVnaXN0ZXJlZC1rZXk=', + registeredAt: '2025-01-31T00:00:00Z', + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.appendFileSync.mockImplementation(() => {}); + mockFs.chmodSync.mockImplementation(() => {}); + + analytics.startTracking('sites.list'); + analytics.finishTracking(true); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['X-Secret-Key']).toBeUndefined(); + }); + }); +});