From 0f9fa5111a91a0d35b3d48537e1190793fa992ca Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sat, 31 Jan 2026 18:39:16 -0800 Subject: [PATCH 01/14] feat(analytics): add anonymous usage analytics module Implements Phase 1 of RFC 001 - local-only anonymous analytics: - Config management with atomic writes and proper permissions (0600) - Event tracking with JSONL storage (~/.lwp/analytics/events.jsonl) - Opt-in prompt for first run (defaults to opt-out in non-interactive) - CI/CD auto-detection (disabled when CI env vars present) - Command exclusion for sensitive commands (wpe.*, analytics.*) - Event rotation at 10k limit (keeps newest 80%) - Summary generation for CLI dashboard Privacy: Only tracks command names, success/failure, and duration. Never tracks arguments, site names, paths, or any PII. Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/analytics.ts | 299 ++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 packages/cli/src/analytics.ts diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts new file mode 100644 index 0000000..47283b9 --- /dev/null +++ b/packages/cli/src/analytics.ts @@ -0,0 +1,299 @@ +/** + * Anonymous Usage Analytics - Phase 1 (Local Only) + * + * Collects minimal anonymous usage data with user consent. + * All data stays local until Phase 2. + * + * Privacy: Only tracks command names, success/failure, and duration. + * Never tracks: arguments, site names, paths, or any PII. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as readline from 'readline'; + +// ============================================================================ +// Types +// ============================================================================ + +interface AnalyticsConfig { + analytics: { + enabled: boolean; + promptedAt: string | null; + }; +} + +interface AnalyticsEvent { + command: string; + success: boolean; + duration_ms: number; + timestamp: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const LWP_DIR = path.join(os.homedir(), '.lwp'); +const CONFIG_PATH = path.join(LWP_DIR, 'config.json'); +const EVENTS_DIR = path.join(LWP_DIR, 'analytics'); +const EVENTS_PATH = path.join(EVENTS_DIR, 'events.jsonl'); + +const MAX_EVENTS = 10000; +const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; + +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 readConfig(): AnalyticsConfig { + try { + if (fs.existsSync(CONFIG_PATH)) { + const data = fs.readFileSync(CONFIG_PATH, 'utf-8'); + const config = JSON.parse(data); + // Validate structure + if (typeof config.analytics?.enabled === 'boolean') { + return config; + } + } + } catch { + // Corrupted config, will regenerate + } + return { analytics: { enabled: false, promptedAt: null } }; +} + +function writeConfig(config: AnalyticsConfig): void { + ensureDir(LWP_DIR); + const tempPath = `${CONFIG_PATH}.${process.pid}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2)); + fs.chmodSync(tempPath, 0o600); + fs.renameSync(tempPath, CONFIG_PATH); +} + +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; +} + +// ============================================================================ +// Opt-In Prompt +// ============================================================================ + +export async function showOptInPrompt(): Promise { + // Skip prompt in non-interactive mode - default to opt-out + if (!process.stdin.isTTY) { + const config = readConfig(); + config.analytics.promptedAt = new Date().toISOString(); + config.analytics.enabled = false; + writeConfig(config); + return false; + } + + console.log(''); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('Help improve lwp?'); + console.log(''); + console.log('We collect anonymous usage data to improve the CLI.'); + console.log('No personal information, site names, or command arguments are collected.'); + console.log(''); + console.log('You can change this anytime: lwp analytics off'); + console.log('Learn more: https://github.com/jpollock/local-addon-cli#analytics'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question('Enable anonymous analytics? [Y/n]: ', (answer) => { + rl.close(); + const enabled = answer.toLowerCase() !== 'n'; + setAnalyticsEnabled(enabled); + console.log(''); + if (enabled) { + console.log('Analytics enabled. Thank you for helping improve lwp!'); + } else { + console.log('Analytics disabled. No data will be collected.'); + } + console.log(''); + resolve(enabled); + }); + }); +} + +// ============================================================================ +// Event Tracking +// ============================================================================ + +function isCommandExcluded(command: string): boolean { + return EXCLUDED_PREFIXES.some((prefix) => command.startsWith(prefix)); +} + +export function recordEvent(event: AnalyticsEvent): void { + try { + if (!isAnalyticsEnabled()) return; + if (isCommandExcluded(event.command)) return; + + ensureDir(EVENTS_DIR); + + // Check event count and rotate if needed + if (fs.existsSync(EVENTS_PATH)) { + const content = fs.readFileSync(EVENTS_PATH, '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(EVENTS_PATH, toKeep.join('\n') + '\n'); + fs.chmodSync(EVENTS_PATH, 0o600); + } + } + + // Append new event + const line = JSON.stringify(event) + '\n'; + fs.appendFileSync(EVENTS_PATH, line); + + // Ensure permissions on first write + fs.chmodSync(EVENTS_PATH, 0o600); + } catch { + // Never let analytics errors affect command execution + } +} + +export function readEvents(): AnalyticsEvent[] { + try { + if (!fs.existsSync(EVENTS_PATH)) return []; + const content = fs.readFileSync(EVENTS_PATH, 'utf-8'); + return content + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch { + return []; + } +} + +export function clearEvents(): void { + try { + if (fs.existsSync(EVENTS_PATH)) { + fs.unlinkSync(EVENTS_PATH); + } + } catch { + // Ignore cleanup errors + } +} + +// ============================================================================ +// 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): void { + if (commandStartTime === null || currentCommandName === null) return; + + const duration = Date.now() - commandStartTime; + recordEvent({ + command: currentCommandName, + success, + duration_ms: duration, + timestamp: new Date().toISOString(), + }); + + commandStartTime = null; + currentCommandName = null; +} + +// ============================================================================ +// Analytics Summary +// ============================================================================ + +export function getStatus(): { enabled: boolean; eventCount: number } { + return { + enabled: isAnalyticsEnabled(), + eventCount: readEvents().length, + }; +} + +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; +} From 8583786146410c2b88e78907dc1fcaa247ebc459 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sat, 31 Jan 2026 18:40:00 -0800 Subject: [PATCH 02/14] feat(cli): add analytics commands and tracking hooks Integrates the analytics module into the CLI: Commands: - `lwp analytics status` - Show enabled/disabled and event count - `lwp analytics on` - Enable analytics - `lwp analytics off` - Disable analytics - `lwp analytics show` - View summary (or --json for raw events) - `lwp analytics reset` - Delete all data and disable Tracking: - Commander hooks track command execution automatically - First-run opt-in prompt (skipped for help/update/analytics commands) - Failed commands tracked via try/catch wrapper - Command path derived from Commander hierarchy (e.g., "sites.list") Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/index.ts | 121 +++++++++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a0afc54..4cfb5f1 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,60 @@ 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}`); + }); + +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') + .action(() => { + const count = analytics.getStatus().eventCount; + analytics.clearEvents(); + analytics.setAnalyticsEnabled(false); + console.log(`Deleted ${count} local events. Analytics disabled.`); + }); + // =========================================== // Helper Functions // =========================================== @@ -1852,15 +1907,63 @@ 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(() => {}); + } + + // 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 (!isUpdateCommand && !isQuiet) { - // Fire and forget - don't block startup - checkForUpdates().catch(() => {}); + if (!shouldSkipPrompt && !analytics.hasBeenPrompted()) { + await analytics.showOptInPrompt(); + } + + // Set up analytics tracking hooks + program.hook('preAction', (thisCommand) => { + const commandPath = getCommandPath(thisCommand); + 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); +}); From 262b1c54464d9fb2a97ac7de9722a2c18d8a02e4 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sat, 31 Jan 2026 18:43:17 -0800 Subject: [PATCH 03/14] test(analytics): add comprehensive unit tests Tests for the analytics module covering: - Config management (read/write, corruption handling) - Environment detection (CI, LWP_ANALYTICS override) - Event tracking (recording, exclusions) - JSONL parsing and summary generation - File permissions Refactored path initialization to use lazy functions for testability. Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/analytics.ts | 61 +++-- packages/cli/tests/analytics.test.ts | 366 +++++++++++++++++++++++++++ 2 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 packages/cli/tests/analytics.test.ts diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 47283b9..09800cc 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -35,14 +35,26 @@ interface AnalyticsEvent { // Constants // ============================================================================ -const LWP_DIR = path.join(os.homedir(), '.lwp'); -const CONFIG_PATH = path.join(LWP_DIR, 'config.json'); -const EVENTS_DIR = path.join(LWP_DIR, 'analytics'); -const EVENTS_PATH = path.join(EVENTS_DIR, 'events.jsonl'); - const MAX_EVENTS = 10000; const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; +// 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', @@ -65,8 +77,9 @@ function ensureDir(dirPath: string): void { function readConfig(): AnalyticsConfig { try { - if (fs.existsSync(CONFIG_PATH)) { - const data = fs.readFileSync(CONFIG_PATH, 'utf-8'); + 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') { @@ -80,11 +93,12 @@ function readConfig(): AnalyticsConfig { } function writeConfig(config: AnalyticsConfig): void { - ensureDir(LWP_DIR); - const tempPath = `${CONFIG_PATH}.${process.pid}.tmp`; + 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, CONFIG_PATH); + fs.renameSync(tempPath, configPath); } export function isAnalyticsEnabled(): boolean { @@ -166,27 +180,30 @@ export function recordEvent(event: AnalyticsEvent): void { if (!isAnalyticsEnabled()) return; if (isCommandExcluded(event.command)) return; - ensureDir(EVENTS_DIR); + const eventsDir = getEventsDir(); + const eventsPath = getEventsPath(); + + ensureDir(eventsDir); // Check event count and rotate if needed - if (fs.existsSync(EVENTS_PATH)) { - const content = fs.readFileSync(EVENTS_PATH, 'utf-8'); + 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(EVENTS_PATH, toKeep.join('\n') + '\n'); - fs.chmodSync(EVENTS_PATH, 0o600); + fs.writeFileSync(eventsPath, toKeep.join('\n') + '\n'); + fs.chmodSync(eventsPath, 0o600); } } // Append new event const line = JSON.stringify(event) + '\n'; - fs.appendFileSync(EVENTS_PATH, line); + fs.appendFileSync(eventsPath, line); // Ensure permissions on first write - fs.chmodSync(EVENTS_PATH, 0o600); + fs.chmodSync(eventsPath, 0o600); } catch { // Never let analytics errors affect command execution } @@ -194,8 +211,9 @@ export function recordEvent(event: AnalyticsEvent): void { export function readEvents(): AnalyticsEvent[] { try { - if (!fs.existsSync(EVENTS_PATH)) return []; - const content = fs.readFileSync(EVENTS_PATH, 'utf-8'); + const eventsPath = getEventsPath(); + if (!fs.existsSync(eventsPath)) return []; + const content = fs.readFileSync(eventsPath, 'utf-8'); return content .trim() .split('\n') @@ -208,8 +226,9 @@ export function readEvents(): AnalyticsEvent[] { export function clearEvents(): void { try { - if (fs.existsSync(EVENTS_PATH)) { - fs.unlinkSync(EVENTS_PATH); + const eventsPath = getEventsPath(); + if (fs.existsSync(eventsPath)) { + fs.unlinkSync(eventsPath); } } catch { // Ignore cleanup errors diff --git a/packages/cli/tests/analytics.test.ts b/packages/cli/tests/analytics.test.ts new file mode 100644 index 0000000..e7445e7 --- /dev/null +++ b/packages/cli/tests/analytics.test.ts @@ -0,0 +1,366 @@ +/** + * Analytics Module Tests + * + * Tests for the anonymous usage analytics module. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Mock fs module before importing analytics +jest.mock('fs'); + +// Mock os.homedir specifically +jest.mock('os', () => ({ + ...jest.requireActual('os'), + homedir: jest.fn(() => '/mock/home'), +})); + +const mockFs = fs as jest.Mocked; +const mockOs = os 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.CI; + delete process.env.GITHUB_ACTIONS; + }); + + describe('isAnalyticsEnabled', () => { + it('returns false when config does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('returns true when analytics is enabled in config', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + 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({ + 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({ + 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({ + 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({ + 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({ + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + + it('handles corrupted config gracefully', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue('{ invalid json'); + + expect(analytics.isAnalyticsEnabled()).toBe(false); + }); + }); + + 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(); + }); + + 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({ + 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({ + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + expect(analytics.hasBeenPrompted()).toBe(true); + }); + }); + + 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"}\n' + + '{"command":"wp","success":false,"duration_ms":200,"timestamp":"2025-01-31T00:01:00Z"}\n' + ); + + const events = analytics.readEvents(); + + expect(events).toHaveLength(2); + expect(events[0].command).toBe('sites.list'); + expect(events[0].success).toBe(true); + 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('getStatus', () => { + it('returns enabled status and event count', () => { + 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({ + 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"}\n' + + '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + ); + } + return ''; + }); + + const status = analytics.getStatus(); + + expect(status.enabled).toBe(true); + expect(status.eventCount).toBe(2); + }); + }); + + 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"}\n' + + '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + + '{"command":"c","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + + '{"command":"d","success":false,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\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"}\n' + + '{"command":"sites.list","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + + '{"command":"wp","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + ); + + const summary = analytics.getSummary(); + + expect(summary).toContain('sites.list'); + expect(summary).toContain('wp'); + }); + }); + + describe('command tracking', () => { + it('tracks command start and finish', () => { + // Set up mocks for recording + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + 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); + }); + + it('does not track when analytics disabled', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + 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({ + 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({ + analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, + }) + ); + + analytics.startTracking('analytics.status'); + analytics.finishTracking(true); + + expect(mockFs.appendFileSync).not.toHaveBeenCalled(); + }); + }); +}); From 38e5fb9141b3c2cfe42beea3198552179b2b75d2 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sat, 31 Jan 2026 18:43:34 -0800 Subject: [PATCH 04/14] docs: update CHANGELOG with analytics feature Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a77ba..c30b166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Anonymous usage analytics with opt-in/opt-out (Phase 1 - local only) + - `lwp analytics status` - Show analytics status + - `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 +- 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.*) + ## [0.0.5] - 2025-01-31 ### Fixed From dbd73d8858148d84ee011471f669b02099ee010e Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 07:21:23 -0800 Subject: [PATCH 05/14] feat(analytics): change to opt-out model (enabled by default) - Analytics now enabled by default to maximize data collection - First-run shows informational message instead of asking for consent - Users can easily disable with `lwp analytics off` - Updated RFC to reflect decision change - Updated tests for new default behavior Co-Authored-By: Claude Sonnet 4.5 --- docs/rfcs/001-anonymous-usage-analytics.md | 315 +++++++++++---------- packages/cli/src/analytics.ts | 46 ++- packages/cli/tests/analytics.test.ts | 8 +- 3 files changed, 188 insertions(+), 181 deletions(-) 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/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 09800cc..3070fdd 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -89,7 +89,8 @@ function readConfig(): AnalyticsConfig { } catch { // Corrupted config, will regenerate } - return { analytics: { enabled: false, promptedAt: null } }; + // Default to enabled (opt-out model) + return { analytics: { enabled: true, promptedAt: null } }; } function writeConfig(config: AnalyticsConfig): void { @@ -125,46 +126,31 @@ export function hasBeenPrompted(): boolean { // ============================================================================ export async function showOptInPrompt(): Promise { - // Skip prompt in non-interactive mode - default to opt-out + // 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) { - const config = readConfig(); - config.analytics.promptedAt = new Date().toISOString(); - config.analytics.enabled = false; - writeConfig(config); - return false; + return true; } + // Show informational message about analytics console.log(''); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('Help improve lwp?'); + console.log('Anonymous usage analytics enabled'); console.log(''); - console.log('We collect anonymous usage data to improve the CLI.'); + 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('You can change this anytime: lwp analytics off'); + console.log('To disable: lwp analytics off'); console.log('Learn more: https://github.com/jpollock/local-addon-cli#analytics'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question('Enable anonymous analytics? [Y/n]: ', (answer) => { - rl.close(); - const enabled = answer.toLowerCase() !== 'n'; - setAnalyticsEnabled(enabled); - console.log(''); - if (enabled) { - console.log('Analytics enabled. Thank you for helping improve lwp!'); - } else { - console.log('Analytics disabled. No data will be collected.'); - } - console.log(''); - resolve(enabled); - }); - }); + return true; } // ============================================================================ diff --git a/packages/cli/tests/analytics.test.ts b/packages/cli/tests/analytics.test.ts index e7445e7..d713d1b 100644 --- a/packages/cli/tests/analytics.test.ts +++ b/packages/cli/tests/analytics.test.ts @@ -36,10 +36,10 @@ describe('analytics', () => { }); describe('isAnalyticsEnabled', () => { - it('returns false when config does not exist', () => { + it('returns true when config does not exist (enabled by default)', () => { mockFs.existsSync.mockReturnValue(false); - expect(analytics.isAnalyticsEnabled()).toBe(false); + expect(analytics.isAnalyticsEnabled()).toBe(true); }); it('returns true when analytics is enabled in config', () => { @@ -112,11 +112,11 @@ describe('analytics', () => { expect(analytics.isAnalyticsEnabled()).toBe(false); }); - it('handles corrupted config gracefully', () => { + it('handles corrupted config gracefully (defaults to enabled)', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue('{ invalid json'); - expect(analytics.isAnalyticsEnabled()).toBe(false); + expect(analytics.isAnalyticsEnabled()).toBe(true); }); }); From da81090962e9cb093d89691ad8ea4087fe024e2d Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 07:29:12 -0800 Subject: [PATCH 06/14] fix(analytics): use actionCommand in preAction hook for proper command path The preAction hook receives (thisCommand, actionCommand) where thisCommand is the root program and actionCommand is the actual command being executed. Using thisCommand resulted in commands being tracked as "unknown". Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4cfb5f1..bbb411d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1944,8 +1944,9 @@ async function main(): Promise { } // Set up analytics tracking hooks - program.hook('preAction', (thisCommand) => { - const commandPath = getCommandPath(thisCommand); + // preAction receives (thisCommand, actionCommand) - we want actionCommand + program.hook('preAction', (thisCommand, actionCommand) => { + const commandPath = getCommandPath(actionCommand); analytics.startTracking(commandPath); }); From 4fd4a6944c6c29f7c483f6ba94e6539281c75fc4 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 07:37:28 -0800 Subject: [PATCH 07/14] test(analytics): add E2E tests for analytics commands Tests cover: - analytics status (current state, event count) - analytics on/off (enable/disable tracking) - analytics show (summary and --json output) - analytics reset (clear events) - command exclusion (analytics.* not tracked) Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/e2e/analytics.e2e.test.ts | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/cli/e2e/analytics.e2e.test.ts 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); + }); + }); +}); From 18e477180140890a6effd546851d8c4b85187705 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 10:02:22 -0800 Subject: [PATCH 08/14] feat(analytics): add Phase 2 - installationId, session_id, and server transmission - Add installationId (UUID) to config, persisted across sessions - Add session_id (UUID) generated per CLI invocation - Extend event format with cli_version, os, node_version - Add error_category support for failure tracking - Add transmitEvent() for server transmission (fire-and-forget) - Add resetAnalytics() to regenerate installationId - Update status command to show installationId - Update tests for Phase 2 fields Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/analytics.ts | 127 ++++++++++++++++-- packages/cli/src/index.ts | 8 +- packages/cli/tests/analytics.test.ts | 186 ++++++++++++++++++++++++--- 3 files changed, 290 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 3070fdd..4d57f9e 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -1,34 +1,56 @@ /** - * Anonymous Usage Analytics - Phase 1 (Local Only) + * Anonymous Usage Analytics - Phase 2 (Server Transmission) * - * Collects minimal anonymous usage data with user consent. - * All data stays local until Phase 2. + * Collects anonymous usage data with user consent and transmits to server. * - * Privacy: Only tracks command names, success/failure, and duration. + * 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) + * - 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 readline from 'readline'; +import * as crypto from 'crypto'; // ============================================================================ // Types // ============================================================================ interface AnalyticsConfig { + installationId?: string; 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; } // ============================================================================ @@ -37,6 +59,15 @@ interface AnalyticsEvent { const MAX_EVENTS = 10000; const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; +const ANALYTICS_ENDPOINT = + process.env.LWP_ANALYTICS_ENDPOINT || 'https://lwp-analytics.jpollock.workers.dev/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 { @@ -75,6 +106,10 @@ function ensureDir(dirPath: string): void { } } +function generateInstallationId(): string { + return crypto.randomUUID(); +} + function readConfig(): AnalyticsConfig { try { const configPath = getConfigPath(); @@ -83,14 +118,22 @@ function readConfig(): AnalyticsConfig { const config = JSON.parse(data); // Validate structure if (typeof config.analytics?.enabled === 'boolean') { + // Ensure installationId exists (migrate from Phase 1) + if (!config.installationId) { + config.installationId = generateInstallationId(); + writeConfig(config); + } return config; } } } catch { // Corrupted config, will regenerate } - // Default to enabled (opt-out model) - return { analytics: { enabled: true, promptedAt: null } }; + // Default to enabled (opt-out model) with new installationId + return { + installationId: generateInstallationId(), + analytics: { enabled: true, promptedAt: null }, + }; } function writeConfig(config: AnalyticsConfig): void { @@ -102,6 +145,14 @@ function writeConfig(config: AnalyticsConfig): void { fs.renameSync(tempPath, configPath); } +export function getInstallationId(): string { + return readConfig().installationId || generateInstallationId(); +} + +export function getSessionId(): string { + return SESSION_ID; +} + export function isAnalyticsEnabled(): boolean { const override = process.env.LWP_ANALYTICS; if (override === '0') return false; @@ -161,6 +212,29 @@ function isCommandExcluded(command: string): boolean { return EXCLUDED_PREFIXES.some((prefix) => command.startsWith(prefix)); } +/** + * Transmit event to analytics server (fire-and-forget) + */ +async function transmitEvent(event: AnalyticsEvent): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TRANSMISSION_TIMEOUT); + + await fetch(ANALYTICS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + } catch { + // Silently ignore transmission errors - never block CLI + } +} + export function recordEvent(event: AnalyticsEvent): void { try { if (!isAnalyticsEnabled()) return; @@ -184,12 +258,17 @@ export function recordEvent(event: AnalyticsEvent): void { } } - // Append new event + // 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 } @@ -221,6 +300,17 @@ export function clearEvents(): void { } } +/** + * Reset analytics: clear events and regenerate installationId + */ +export function resetAnalytics(): void { + clearEvents(); + const config = readConfig(); + config.installationId = generateInstallationId(); + config.analytics.enabled = false; + writeConfig(config); +} + // ============================================================================ // Command Tracking (for Commander hooks) // ============================================================================ @@ -233,16 +323,28 @@ export function startTracking(commandName: string): void { currentCommandName = commandName; } -export function finishTracking(success: boolean): void { +export function finishTracking(success: boolean, errorCategory?: ErrorCategory): void { if (commandStartTime === null || currentCommandName === null) return; const duration = Date.now() - commandStartTime; - recordEvent({ + 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; @@ -252,10 +354,11 @@ export function finishTracking(success: boolean): void { // Analytics Summary // ============================================================================ -export function getStatus(): { enabled: boolean; eventCount: number } { +export function getStatus(): { enabled: boolean; eventCount: number; installationId: string } { return { enabled: isAnalyticsEnabled(), eventCount: readEvents().length, + installationId: getInstallationId(), }; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bbb411d..fc17b20 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1745,6 +1745,7 @@ analyticsCmd 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 @@ -1778,12 +1779,11 @@ analyticsCmd analyticsCmd .command('reset') - .description('Delete all analytics data') + .description('Delete all analytics data and regenerate installation ID') .action(() => { const count = analytics.getStatus().eventCount; - analytics.clearEvents(); - analytics.setAnalyticsEnabled(false); - console.log(`Deleted ${count} local events. Analytics disabled.`); + analytics.resetAnalytics(); + console.log(`Deleted ${count} local events. Installation ID regenerated. Analytics disabled.`); }); // =========================================== diff --git a/packages/cli/tests/analytics.test.ts b/packages/cli/tests/analytics.test.ts index d713d1b..dc1b326 100644 --- a/packages/cli/tests/analytics.test.ts +++ b/packages/cli/tests/analytics.test.ts @@ -1,24 +1,36 @@ /** * Analytics Module Tests * - * Tests for the anonymous usage analytics module. + * Tests for the anonymous usage analytics module (Phase 2). */ 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.homedir specifically +// 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'; @@ -31,6 +43,7 @@ describe('analytics', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.LWP_ANALYTICS; + delete process.env.LWP_ANALYTICS_ENDPOINT; delete process.env.CI; delete process.env.GITHUB_ACTIONS; }); @@ -46,6 +59,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -57,6 +71,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: false, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -69,6 +84,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -81,6 +97,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: false, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -93,6 +110,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -105,6 +123,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -135,6 +154,7 @@ describe('analytics', () => { 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)', () => { @@ -161,6 +181,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: false, promptedAt: null }, }) ); @@ -172,6 +193,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -180,6 +202,38 @@ describe('analytics', () => { }); }); + 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('readEvents', () => { it('returns empty array when events file does not exist', () => { mockFs.existsSync.mockReturnValue(false); @@ -190,8 +244,8 @@ describe('analytics', () => { 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"}\n' + - '{"command":"wp","success":false,"duration_ms":200,"timestamp":"2025-01-31T00:01:00Z"}\n' + '{"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(); @@ -199,6 +253,7 @@ describe('analytics', () => { 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); }); @@ -228,8 +283,41 @@ describe('analytics', () => { }); }); + describe('resetAnalytics', () => { + it('clears events and regenerates installationId', () => { + mockFs.existsSync.mockImplementation((p) => { + if (p === configPath) return true; + if (p === eventsPath) return true; + return false; + }); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + installationId: 'old-id', + 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 + 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.analytics.enabled).toBe(false); + }); + }); + describe('getStatus', () => { - it('returns enabled status and event count', () => { + it('returns enabled status, event count, and installationId', () => { mockFs.existsSync.mockImplementation((p) => { if (p === configPath) return true; if (p === eventsPath) return true; @@ -238,13 +326,14 @@ describe('analytics', () => { 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"}\n' + - '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + '{"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 ''; @@ -254,6 +343,7 @@ describe('analytics', () => { expect(status.enabled).toBe(true); expect(status.eventCount).toBe(2); + expect(status.installationId).toBe('test-install-id'); }); }); @@ -267,10 +357,10 @@ describe('analytics', () => { 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"}\n' + - '{"command":"b","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + - '{"command":"c","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + - '{"command":"d","success":false,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + '{"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(); @@ -282,9 +372,9 @@ describe('analytics', () => { 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"}\n' + - '{"command":"sites.list","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\n' + - '{"command":"wp","success":true,"duration_ms":1,"timestamp":"2025-01-31T00:00:00Z"}\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":"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(); @@ -295,7 +385,7 @@ describe('analytics', () => { }); describe('command tracking', () => { - it('tracks command start and finish', () => { + it('tracks command with Phase 2 fields', () => { // Set up mocks for recording mockFs.existsSync.mockImplementation((p) => { if (p === configPath) return true; @@ -303,6 +393,7 @@ describe('analytics', () => { }); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-install-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -319,12 +410,43 @@ describe('analytics', () => { 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' }, }) ); @@ -339,6 +461,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -353,6 +476,7 @@ describe('analytics', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue( JSON.stringify({ + installationId: 'test-id', analytics: { enabled: true, promptedAt: '2025-01-31T00:00:00Z' }, }) ); @@ -362,5 +486,37 @@ describe('analytics', () => { expect(mockFs.appendFileSync).not.toHaveBeenCalled(); }); + + it('transmits event to server', 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', + 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); + + // Wait for the fire-and-forget fetch to be called + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify fetch was called (fire-and-forget) + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/events'); + expect(fetchCall[1].method).toBe('POST'); + }); }); }); From 35a3fbc6c5b764854d02a8a31270ea0030cba9ad Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 10:02:29 -0800 Subject: [PATCH 09/14] feat(analytics-worker): add Cloudflare Worker for analytics ingestion - Add D1 schema for events table with indexes - Implement POST /v1/events endpoint for event ingestion - Implement GET /v1/stats endpoint for aggregate statistics - Add health check endpoint - Add CORS support for cross-origin requests Co-Authored-By: Claude Sonnet 4.5 --- packages/analytics-worker/package.json | 16 ++ packages/analytics-worker/schema.sql | 23 +++ packages/analytics-worker/src/index.ts | 191 ++++++++++++++++++++++++ packages/analytics-worker/tsconfig.json | 13 ++ packages/analytics-worker/wrangler.toml | 8 + 5 files changed, 251 insertions(+) create mode 100644 packages/analytics-worker/package.json create mode 100644 packages/analytics-worker/schema.sql create mode 100644 packages/analytics-worker/src/index.ts create mode 100644 packages/analytics-worker/tsconfig.json create mode 100644 packages/analytics-worker/wrangler.toml diff --git a/packages/analytics-worker/package.json b/packages/analytics-worker/package.json new file mode 100644 index 0000000..7deb4db --- /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": "^3.22.0" + } +} diff --git a/packages/analytics-worker/schema.sql b/packages/analytics-worker/schema.sql new file mode 100644 index 0000000..b9862a8 --- /dev/null +++ b/packages/analytics-worker/schema.sql @@ -0,0 +1,23 @@ +-- LWP Analytics Schema +-- Stores anonymous CLI usage events + +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 +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_installation ON events(installation_id); +CREATE INDEX IF NOT EXISTS idx_timestamp ON events(timestamp); +CREATE INDEX IF NOT EXISTS idx_command ON events(command); +CREATE INDEX IF NOT EXISTS idx_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..75fa5ea --- /dev/null +++ b/packages/analytics-worker/src/index.ts @@ -0,0 +1,191 @@ +/** + * LWP Analytics Worker + * + * Receives anonymous usage analytics from the lwp CLI. + * Stores events in D1 (SQLite at the edge). + */ + +export interface Env { + DB: D1Database; +} + +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': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +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 + if (url.pathname === '/v1/events' && request.method === 'POST') { + return handleEventIngestion(request, env); + } + + // Stats endpoint (for admin/debugging) + if (url.pathname === '/v1/stats' && request.method === 'GET') { + return handleStats(env); + } + + return new Response('Not Found', { status: 404, headers: corsHeaders }); + }, +}; + +async function handleEventIngestion(request: Request, env: Env): Promise { + try { + const event = (await request.json()) as AnalyticsEvent; + + // Validate required fields + 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 }, + }); + } + + // Insert into D1 + await env.DB.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 202 Accepted (fire-and-forget from CLI perspective) + 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(env: Env): Promise { + try { + // Get basic stats + const totalEvents = await env.DB.prepare('SELECT COUNT(*) as count FROM events').first<{ + count: number; + }>(); + + const uniqueInstallations = await env.DB.prepare( + 'SELECT COUNT(DISTINCT installation_id) as count FROM events' + ).first<{ count: number }>(); + + const commandCounts = await env.DB.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.DB.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.DB.prepare( + `SELECT os, COUNT(*) as count + FROM events + GROUP BY os + ORDER BY count DESC` + ).all<{ os: string; count: number }>(); + + const successRate = await env.DB.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?.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 }, + }); + } +} 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..57f92e9 --- /dev/null +++ b/packages/analytics-worker/wrangler.toml @@ -0,0 +1,8 @@ +name = "lwp-analytics" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[[d1_databases]] +binding = "DB" +database_name = "lwp-analytics" +database_id = "placeholder-will-be-set-after-creation" From bb4d9f7f435f8070e21590bd8dc6ba5485748aab Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 10:02:48 -0800 Subject: [PATCH 10/14] docs: update CHANGELOG for Phase 2 analytics Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30b166..34fafb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Anonymous usage analytics with opt-in/opt-out (Phase 1 - local only) - - `lwp analytics status` - Show analytics status +- Anonymous usage analytics with server transmission (Phase 2) + - `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 + - `lwp analytics reset` - Delete all data and regenerate installation ID +- 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 - 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 From 04b5e4b9c9c8e8ae1ae7b1257ae2e638a7294953 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 17:41:06 -0800 Subject: [PATCH 11/14] feat(analytics): add Phase 3 - HMAC signed authentication - Add secretKey generation (32 random bytes, base64) for HMAC signing - Sign all event transmissions with HMAC-SHA256 - First request sends secretKey, server stores for future verification - Add installations table to D1 for storing secret keys - Protect /v1/stats endpoint with ADMIN_TOKEN - Add /dashboard/:installationId with signed URL (1 hour expiry) - Add `lwp analytics dashboard` command Co-Authored-By: Claude Sonnet 4.5 --- packages/analytics-worker/.gitignore | 2 + packages/analytics-worker/package-lock.json | 1512 +++++++++++++++++++ packages/analytics-worker/package.json | 2 +- packages/analytics-worker/schema.sql | 21 +- packages/analytics-worker/src/index.ts | 316 +++- packages/analytics-worker/wrangler.toml | 5 +- packages/cli/src/analytics.ts | 126 +- packages/cli/src/index.ts | 9 + 8 files changed, 1938 insertions(+), 55 deletions(-) create mode 100644 packages/analytics-worker/.gitignore create mode 100644 packages/analytics-worker/package-lock.json 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 index 7deb4db..0999719 100644 --- a/packages/analytics-worker/package.json +++ b/packages/analytics-worker/package.json @@ -11,6 +11,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20240117.0", - "wrangler": "^3.22.0" + "wrangler": "^4.61.1" } } diff --git a/packages/analytics-worker/schema.sql b/packages/analytics-worker/schema.sql index b9862a8..7836a50 100644 --- a/packages/analytics-worker/schema.sql +++ b/packages/analytics-worker/schema.sql @@ -1,6 +1,14 @@ -- LWP Analytics Schema --- Stores anonymous CLI usage events +-- 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, @@ -13,11 +21,12 @@ CREATE TABLE IF NOT EXISTS events ( os TEXT NOT NULL, node_version TEXT NOT NULL, timestamp TEXT NOT NULL, - received_at TEXT DEFAULT CURRENT_TIMESTAMP + received_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (installation_id) REFERENCES installations(installation_id) ); -- Indexes for common queries -CREATE INDEX IF NOT EXISTS idx_installation ON events(installation_id); -CREATE INDEX IF NOT EXISTS idx_timestamp ON events(timestamp); -CREATE INDEX IF NOT EXISTS idx_command ON events(command); -CREATE INDEX IF NOT EXISTS idx_cli_version ON events(cli_version); +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 index 75fa5ea..25aea7e 100644 --- a/packages/analytics-worker/src/index.ts +++ b/packages/analytics-worker/src/index.ts @@ -2,11 +2,13 @@ * 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 { - DB: D1Database; + lwp_analytics: D1Database; + ADMIN_TOKEN?: string; } interface AnalyticsEvent { @@ -25,8 +27,8 @@ interface AnalyticsEvent { // CORS headers for cross-origin requests const corsHeaders = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Installation-Id, X-Signature, X-Secret-Key', }; export default { @@ -45,25 +47,139 @@ export default { }); } - // Event ingestion endpoint + // Event ingestion endpoint (authenticated) if (url.pathname === '/v1/events' && request.method === 'POST') { return handleEventIngestion(request, env); } - // Stats endpoint (for admin/debugging) + // Stats endpoint (admin only) if (url.pathname === '/v1/stats' && request.method === 'GET') { - return handleStats(env); + 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 event = (await request.json()) as AnalyticsEvent; + 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; - // Validate required fields if ( !event.command || typeof event.success !== 'boolean' || @@ -81,9 +197,18 @@ async function handleEventIngestion(request: Request, env: Env): Promise { +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 { - // Get basic stats - const totalEvents = await env.DB.prepare('SELECT COUNT(*) as count FROM events').first<{ - count: number; - }>(); + const totalEvents = await env.lwp_analytics + .prepare('SELECT COUNT(*) as count FROM events') + .first<{ count: number }>(); - const uniqueInstallations = await env.DB.prepare( - 'SELECT COUNT(DISTINCT installation_id) 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.DB.prepare( - `SELECT command, COUNT(*) as count + 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 }>(); + ) + .all<{ command: string; count: number }>(); - const versionCounts = await env.DB.prepare( - `SELECT cli_version, COUNT(*) as count + 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 }>(); + ) + .all<{ cli_version: string; count: number }>(); - const osCounts = await env.DB.prepare( - `SELECT os, COUNT(*) as count + 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 }>(); + ) + .all<{ os: string; count: number }>(); - const successRate = await env.DB.prepare( - `SELECT + 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 }>(); + ) + .first<{ successes: number; total: number }>(); return new Response( JSON.stringify({ total_events: totalEvents?.count || 0, unique_installations: uniqueInstallations?.count || 0, success_rate: - successRate?.total > 0 + successRate && successRate.total > 0 ? ((successRate.successes / successRate.total) * 100).toFixed(1) + '%' : 'N/A', top_commands: commandCounts?.results || [], @@ -189,3 +329,111 @@ async function handleStats(env: Env): Promise { }); } } + +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/wrangler.toml b/packages/analytics-worker/wrangler.toml index 57f92e9..311f80f 100644 --- a/packages/analytics-worker/wrangler.toml +++ b/packages/analytics-worker/wrangler.toml @@ -3,6 +3,7 @@ main = "src/index.ts" compatibility_date = "2024-01-01" [[d1_databases]] -binding = "DB" +binding = "lwp_analytics" database_name = "lwp-analytics" -database_id = "placeholder-will-be-set-after-creation" +database_id = "c3633feb-70a4-4827-80d2-b14ebf84e3dd" + diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 4d57f9e..1853a99 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -1,13 +1,15 @@ /** - * Anonymous Usage Analytics - Phase 2 (Server Transmission) + * 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 */ @@ -22,6 +24,8 @@ import * as crypto from 'crypto'; interface AnalyticsConfig { installationId?: string; + secretKey?: string; + registeredAt?: string; // When secretKey was first sent to server analytics: { enabled: boolean; promptedAt: string | null; @@ -59,8 +63,10 @@ interface AnalyticsEvent { const MAX_EVENTS = 10000; const EXCLUDED_PREFIXES = ['wpe.', 'analytics.']; -const ANALYTICS_ENDPOINT = - process.env.LWP_ANALYTICS_ENDPOINT || 'https://lwp-analytics.jpollock.workers.dev/v1/events'; +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 @@ -110,6 +116,10 @@ function generateInstallationId(): string { return crypto.randomUUID(); } +function generateSecretKey(): string { + return crypto.randomBytes(32).toString('base64'); +} + function readConfig(): AnalyticsConfig { try { const configPath = getConfigPath(); @@ -118,9 +128,21 @@ function readConfig(): AnalyticsConfig { const config = JSON.parse(data); // Validate structure if (typeof config.analytics?.enabled === 'boolean') { - // Ensure installationId exists (migrate from Phase 1) + 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; @@ -129,9 +151,10 @@ function readConfig(): AnalyticsConfig { } catch { // Corrupted config, will regenerate } - // Default to enabled (opt-out model) with new installationId + // Default to enabled (opt-out model) with new credentials return { installationId: generateInstallationId(), + secretKey: generateSecretKey(), analytics: { enabled: true, promptedAt: null }, }; } @@ -149,6 +172,10 @@ export function getInstallationId(): string { return readConfig().installationId || generateInstallationId(); } +export function getSecretKey(): string { + return readConfig().secretKey || generateSecretKey(); +} + export function getSessionId(): string { return SESSION_ID; } @@ -172,6 +199,34 @@ 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'); +} + +/** + * Check if this installation has been registered with the server + */ +function isRegistered(): boolean { + return readConfig().registeredAt !== null; +} + +/** + * Mark this installation as registered + */ +function markAsRegistered(): void { + const config = readConfig(); + config.registeredAt = new Date().toISOString(); + writeConfig(config); +} + // ============================================================================ // Opt-In Prompt // ============================================================================ @@ -213,23 +268,45 @@ function isCommandExcluded(command: string): boolean { } /** - * Transmit event to analytics server (fire-and-forget) + * 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); - await fetch(ANALYTICS_ENDPOINT, { + const response = await fetch(ANALYTICS_ENDPOINT, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(event), + 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 } @@ -301,12 +378,14 @@ export function clearEvents(): void { } /** - * Reset analytics: clear events and regenerate installationId + * 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); } @@ -350,6 +429,29 @@ export function finishTracking(success: boolean, errorCategory?: ErrorCategory): 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 // ============================================================================ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fc17b20..b749a15 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1786,6 +1786,15 @@ analyticsCmd 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 // =========================================== From a2e5e5aa7790808f02abf8ccf127151db49a5ea7 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 17:42:52 -0800 Subject: [PATCH 12/14] test(analytics): add Phase 3 tests for HMAC signing - Add tests for getSecretKey() - Add tests for getDashboardUrl() - Update resetAnalytics test to verify secretKey regeneration - Add tests for X-Signature and X-Secret-Key headers - Verify first request sends secretKey, subsequent requests don't Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/tests/analytics.test.ts | 127 +++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/cli/tests/analytics.test.ts b/packages/cli/tests/analytics.test.ts index dc1b326..ab0f4f0 100644 --- a/packages/cli/tests/analytics.test.ts +++ b/packages/cli/tests/analytics.test.ts @@ -1,7 +1,7 @@ /** * Analytics Module Tests * - * Tests for the anonymous usage analytics module (Phase 2). + * Tests for the anonymous usage analytics module (Phase 3). */ import * as fs from 'fs'; @@ -234,6 +234,50 @@ describe('analytics', () => { }); }); + 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); @@ -284,7 +328,7 @@ describe('analytics', () => { }); describe('resetAnalytics', () => { - it('clears events and regenerates installationId', () => { + it('clears events and regenerates installationId and secretKey', () => { mockFs.existsSync.mockImplementation((p) => { if (p === configPath) return true; if (p === eventsPath) return true; @@ -293,6 +337,8 @@ describe('analytics', () => { 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' }, }) ); @@ -307,11 +353,14 @@ describe('analytics', () => { // Should delete events expect(mockFs.unlinkSync).toHaveBeenCalledWith(eventsPath); - // Should write new config with new installationId + // 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); }); }); @@ -487,7 +536,7 @@ describe('analytics', () => { expect(mockFs.appendFileSync).not.toHaveBeenCalled(); }); - it('transmits event to server', async () => { + it('transmits event to server with HMAC signature', async () => { // Set up fetch mock const mockFetch = jest.fn().mockResolvedValue({ ok: true }); global.fetch = mockFetch; @@ -499,12 +548,15 @@ describe('analytics', () => { 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); @@ -512,11 +564,76 @@ describe('analytics', () => { // Wait for the fire-and-forget fetch to be called await new Promise((resolve) => setTimeout(resolve, 10)); - // Verify fetch was called (fire-and-forget) + // 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(); }); }); }); From d5d59875505a626ac2b4f5f5984705fc6254f17d Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 17:43:26 -0800 Subject: [PATCH 13/14] docs: update CHANGELOG for Phase 3 signed authentication Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fafb8..9d36ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Anonymous usage analytics with server transmission (Phase 2) +- 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 installation ID + - `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.*) From c55fdd2b0da2c4bdc640f7e2bd0c73a5e428b6e2 Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Sun, 1 Feb 2026 18:12:50 -0800 Subject: [PATCH 14/14] fix(analytics): remove unused isRegistered function Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/analytics.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/cli/src/analytics.ts b/packages/cli/src/analytics.ts index 1853a99..c29dbd9 100644 --- a/packages/cli/src/analytics.ts +++ b/packages/cli/src/analytics.ts @@ -211,13 +211,6 @@ function signData(data: string, secretKey: string): string { return crypto.createHmac('sha256', key).update(data).digest('hex'); } -/** - * Check if this installation has been registered with the server - */ -function isRegistered(): boolean { - return readConfig().registeredAt !== null; -} - /** * Mark this installation as registered */