apple-tools-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Security validation and escaping utilities for apple-tools-mcp
3
+ *
4
+ * This module centralizes input validation to prevent:
5
+ * - Path traversal attacks
6
+ * - Command injection
7
+ * - SQL injection
8
+ * - Integer overflow
9
+ */
10
+
11
+ import path from "path";
12
+ import fs from "fs";
13
+
14
+ // ============ PATH VALIDATION ============
15
+
16
+ /**
17
+ * Validates that a file path is within an allowed directory and has expected extension
18
+ * Prevents path traversal attacks like "../../../etc/passwd"
19
+ *
20
+ * @param {string} filePath - The path to validate
21
+ * @param {string} allowedDir - The directory the path must be within
22
+ * @param {string[]} allowedExtensions - Array of allowed extensions (e.g., ['.emlx'])
23
+ * @returns {string} The resolved, validated path
24
+ * @throws {Error} If validation fails
25
+ */
26
+ export function validateFilePath(filePath, allowedDir, allowedExtensions = []) {
27
+ if (!filePath || typeof filePath !== 'string') {
28
+ throw new Error('File path is required');
29
+ }
30
+
31
+ // Check extension if specified
32
+ if (allowedExtensions.length > 0) {
33
+ const ext = path.extname(filePath).toLowerCase();
34
+ if (!allowedExtensions.includes(ext)) {
35
+ throw new Error(`Invalid file extension. Allowed: ${allowedExtensions.join(', ')}`);
36
+ }
37
+ }
38
+
39
+ // Resolve to absolute path (handles ../ etc)
40
+ const resolvedPath = path.resolve(filePath);
41
+ const resolvedAllowedDir = path.resolve(allowedDir);
42
+
43
+ // Ensure the resolved path starts with the allowed directory
44
+ if (!resolvedPath.startsWith(resolvedAllowedDir + path.sep) && resolvedPath !== resolvedAllowedDir) {
45
+ throw new Error('Access denied: path outside allowed directory');
46
+ }
47
+
48
+ return resolvedPath;
49
+ }
50
+
51
+ /**
52
+ * Validates an email file path specifically
53
+ * @param {string} filePath - The email file path
54
+ * @param {string} mailDir - The Mail directory (usually ~/Library/Mail)
55
+ * @returns {string} The validated path
56
+ */
57
+ export function validateEmailPath(filePath, mailDir) {
58
+ return validateFilePath(filePath, mailDir, ['.emlx']);
59
+ }
60
+
61
+ // ============ NUMERIC VALIDATION ============
62
+
63
+ /**
64
+ * Validates and constrains a limit parameter
65
+ * Prevents integer overflow and excessively large queries
66
+ *
67
+ * @param {any} value - The value to validate
68
+ * @param {number} defaultValue - Default if invalid (default: 30)
69
+ * @param {number} max - Maximum allowed value (default: 1000)
70
+ * @returns {number} A safe integer within bounds
71
+ */
72
+ export function validateLimit(value, defaultValue = 30, max = 1000) {
73
+ const parsed = parseInt(value);
74
+ if (!Number.isInteger(parsed) || parsed < 1) {
75
+ return defaultValue;
76
+ }
77
+ return Math.min(parsed, max);
78
+ }
79
+
80
+ /**
81
+ * Validates a days_back parameter
82
+ * @param {any} value - The value to validate
83
+ * @param {number} max - Maximum days back (default: 3650 = ~10 years)
84
+ * @returns {number} A safe integer >= 0
85
+ */
86
+ export function validateDaysBack(value, max = 3650) {
87
+ const parsed = parseInt(value);
88
+ if (!Number.isInteger(parsed) || parsed < 0) {
89
+ return 0;
90
+ }
91
+ return Math.min(parsed, max);
92
+ }
93
+
94
+ /**
95
+ * Validates a week offset parameter
96
+ * @param {any} value - The value to validate
97
+ * @param {number} max - Maximum weeks ahead (default: 52)
98
+ * @returns {number} A safe integer >= 0
99
+ */
100
+ export function validateWeekOffset(value, max = 52) {
101
+ const parsed = parseInt(value);
102
+ if (!Number.isInteger(parsed) || parsed < 0) {
103
+ return 0;
104
+ }
105
+ return Math.min(parsed, max);
106
+ }
107
+
108
+ // ============ STRING ESCAPING ============
109
+
110
+ /**
111
+ * Escapes a string for use in AppleScript double-quoted strings
112
+ * Prevents AppleScript injection attacks
113
+ *
114
+ * @param {string} str - The string to escape
115
+ * @returns {string} Escaped string safe for AppleScript
116
+ */
117
+ export function escapeAppleScript(str) {
118
+ if (!str || typeof str !== 'string') {
119
+ return '';
120
+ }
121
+ // Escape backslashes first, then double quotes
122
+ return str
123
+ .replace(/\\/g, '\\\\')
124
+ .replace(/"/g, '\\"');
125
+ }
126
+
127
+ /**
128
+ * Validates a mailbox name for use in AppleScript
129
+ * Only allows safe characters to prevent injection
130
+ *
131
+ * @param {string} mailbox - The mailbox name
132
+ * @returns {string|null} Validated mailbox name or null if invalid
133
+ */
134
+ export function validateMailboxName(mailbox) {
135
+ if (!mailbox || typeof mailbox !== 'string') {
136
+ return null;
137
+ }
138
+
139
+ // Only allow alphanumeric, spaces, hyphens, underscores, and periods
140
+ // This prevents any AppleScript injection
141
+ if (!/^[a-zA-Z0-9\s\-_.]+$/.test(mailbox)) {
142
+ return null;
143
+ }
144
+
145
+ // Also limit length
146
+ if (mailbox.length > 100) {
147
+ return null;
148
+ }
149
+
150
+ return mailbox;
151
+ }
152
+
153
+ /**
154
+ * Escapes a string for use in SQL single-quoted strings
155
+ * Note: Prefer parameterized queries when possible
156
+ *
157
+ * @param {string} str - The string to escape
158
+ * @returns {string} Escaped string safe for SQL
159
+ */
160
+ export function escapeSQL(str) {
161
+ if (!str || typeof str !== 'string') {
162
+ return '';
163
+ }
164
+ // Double single quotes and escape backslashes
165
+ return str
166
+ .replace(/\\/g, '\\\\')
167
+ .replace(/'/g, "''");
168
+ }
169
+
170
+ /**
171
+ * Validates and sanitizes an ID string for LanceDB queries
172
+ * @param {string} id - The ID to validate
173
+ * @returns {string|null} Validated ID or null if invalid
174
+ */
175
+ export function validateLanceDBId(id) {
176
+ if (!id || typeof id !== 'string') {
177
+ return null;
178
+ }
179
+
180
+ // IDs should only contain safe characters
181
+ // Allow alphanumeric, spaces, hyphens, colons, commas, periods
182
+ if (!/^[a-zA-Z0-9\s\-:,.]+$/.test(id)) {
183
+ return null;
184
+ }
185
+
186
+ // Limit length
187
+ if (id.length > 500) {
188
+ return null;
189
+ }
190
+
191
+ return id;
192
+ }
193
+
194
+ // ============ SEARCH QUERY VALIDATION ============
195
+
196
+ /**
197
+ * Validates a search query string
198
+ * @param {string} query - The search query
199
+ * @param {number} maxLength - Maximum allowed length (default: 1000)
200
+ * @returns {string} Validated query
201
+ * @throws {Error} If query is invalid
202
+ */
203
+ export function validateSearchQuery(query, maxLength = 1000) {
204
+ if (!query || typeof query !== 'string') {
205
+ throw new Error('Search query is required');
206
+ }
207
+
208
+ const trimmed = query.trim();
209
+ if (trimmed.length === 0) {
210
+ throw new Error('Search query cannot be empty');
211
+ }
212
+
213
+ if (trimmed.length > maxLength) {
214
+ return trimmed.substring(0, maxLength);
215
+ }
216
+
217
+ return trimmed;
218
+ }
219
+
220
+ /**
221
+ * Validates a contact/name string
222
+ * @param {string} contact - The contact name or identifier
223
+ * @param {number} maxLength - Maximum length (default: 200)
224
+ * @returns {string|null} Validated contact or null
225
+ */
226
+ export function validateContact(contact, maxLength = 200) {
227
+ if (!contact || typeof contact !== 'string') {
228
+ return null;
229
+ }
230
+
231
+ const trimmed = contact.trim();
232
+ if (trimmed.length === 0 || trimmed.length > maxLength) {
233
+ return null;
234
+ }
235
+
236
+ return trimmed;
237
+ }
238
+
239
+ // ============ DATE VALIDATION ============
240
+
241
+ /**
242
+ * Validates a date string or timestamp
243
+ * @param {string|number} date - The date to validate
244
+ * @returns {Date|null} Validated Date object or null
245
+ */
246
+ export function validateDate(date) {
247
+ if (!date) {
248
+ return null;
249
+ }
250
+
251
+ try {
252
+ const d = new Date(date);
253
+ if (isNaN(d.getTime())) {
254
+ return null;
255
+ }
256
+
257
+ // Sanity check: date should be between year 1990 and 2100
258
+ const year = d.getFullYear();
259
+ if (year < 1990 || year > 2100) {
260
+ return null;
261
+ }
262
+
263
+ return d;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ // ============ ENVIRONMENT VALIDATION ============
270
+
271
+ /**
272
+ * Gets a validated home directory path
273
+ * @returns {string} The home directory
274
+ * @throws {Error} If HOME is invalid
275
+ */
276
+ export function getValidatedHome() {
277
+ const home = process.env.HOME;
278
+
279
+ if (!home || typeof home !== 'string') {
280
+ throw new Error('HOME environment variable is not set');
281
+ }
282
+
283
+ // Ensure it's an absolute path
284
+ if (!path.isAbsolute(home)) {
285
+ throw new Error('HOME must be an absolute path');
286
+ }
287
+
288
+ // Ensure it exists and is a directory
289
+ try {
290
+ const stats = fs.statSync(home);
291
+ if (!stats.isDirectory()) {
292
+ throw new Error('HOME is not a directory');
293
+ }
294
+ } catch (e) {
295
+ if (e.code === 'ENOENT') {
296
+ throw new Error('HOME directory does not exist');
297
+ }
298
+ throw e;
299
+ }
300
+
301
+ return home;
302
+ }
303
+
304
+ // ============ REGEX SAFETY ============
305
+
306
+ /**
307
+ * Escapes special regex characters in a string
308
+ * Prevents ReDoS when using user input in regex patterns
309
+ *
310
+ * @param {string} str - The string to escape
311
+ * @returns {string} Escaped string safe for use in RegExp
312
+ */
313
+ export function escapeRegex(str) {
314
+ if (!str || typeof str !== 'string') {
315
+ return '';
316
+ }
317
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
318
+ }
319
+
320
+ /**
321
+ * Safely execute a regex match with input length limits
322
+ * Prevents ReDoS by limiting input size
323
+ *
324
+ * @param {string} input - The input string to match against
325
+ * @param {RegExp} pattern - The regex pattern
326
+ * @param {number} maxLength - Maximum input length (default: 10000)
327
+ * @returns {RegExpMatchArray|null} Match result or null
328
+ */
329
+ export function safeMatch(input, pattern, maxLength = 10000) {
330
+ if (!input || typeof input !== 'string') {
331
+ return null;
332
+ }
333
+ // Truncate overly long inputs to prevent ReDoS
334
+ const safeInput = input.length > maxLength ? input.substring(0, maxLength) : input;
335
+ try {
336
+ return safeInput.match(pattern);
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Safely execute a regex replace with input length limits
344
+ *
345
+ * @param {string} input - The input string
346
+ * @param {RegExp|string} pattern - The regex pattern or string
347
+ * @param {string|Function} replacement - The replacement string or function
348
+ * @param {number} maxLength - Maximum input length (default: 50000)
349
+ * @returns {string} Replaced string
350
+ */
351
+ export function safeReplace(input, pattern, replacement, maxLength = 50000) {
352
+ if (!input || typeof input !== 'string') {
353
+ return '';
354
+ }
355
+ // Truncate overly long inputs
356
+ const safeInput = input.length > maxLength ? input.substring(0, maxLength) : input;
357
+ try {
358
+ return safeInput.replace(pattern, replacement);
359
+ } catch {
360
+ return safeInput;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Strip HTML tags safely without ReDoS vulnerability
366
+ * Uses iterative approach instead of complex regex
367
+ *
368
+ * @param {string} html - The HTML string
369
+ * @param {number} maxLength - Maximum input length (default: 100000)
370
+ * @returns {string} Text with HTML tags removed
371
+ */
372
+ export function stripHtmlTags(html, maxLength = 100000) {
373
+ if (!html || typeof html !== 'string') {
374
+ return '';
375
+ }
376
+
377
+ // Truncate overly long inputs
378
+ let text = html.length > maxLength ? html.substring(0, maxLength) : html;
379
+
380
+ // Simple state machine approach - more predictable than regex
381
+ let result = '';
382
+ let inTag = false;
383
+
384
+ for (let i = 0; i < text.length; i++) {
385
+ const char = text[i];
386
+ if (char === '<') {
387
+ inTag = true;
388
+ } else if (char === '>') {
389
+ inTag = false;
390
+ result += ' '; // Replace tag with space
391
+ } else if (!inTag) {
392
+ result += char;
393
+ }
394
+ }
395
+
396
+ // Normalize whitespace
397
+ return result.replace(/\s+/g, ' ').trim();
398
+ }
399
+
400
+ // ============ DATE UTILITIES ============
401
+
402
+ /**
403
+ * Mac Absolute Time epoch constant
404
+ * Mac epoch is January 1, 2001 00:00:00 UTC
405
+ * Unix epoch is January 1, 1970 00:00:00 UTC
406
+ * Difference is 978307200 seconds
407
+ */
408
+ const MAC_ABSOLUTE_EPOCH = 978307200;
409
+
410
+ /**
411
+ * Convert Mac Absolute Time to Unix timestamp (milliseconds)
412
+ * Handles both seconds and nanoseconds formats
413
+ *
414
+ * @param {number} macTime - Mac absolute time value
415
+ * @returns {number} Unix timestamp in milliseconds
416
+ */
417
+ export function macAbsoluteTimeToDate(macTime) {
418
+ if (macTime === null || macTime === undefined || isNaN(macTime)) {
419
+ return 0;
420
+ }
421
+
422
+ let seconds = macTime;
423
+
424
+ // Detect nanoseconds (Messages database uses nanoseconds)
425
+ // If the value is larger than reasonable for seconds (> year 3000), assume nanoseconds
426
+ if (macTime > 50000000000) {
427
+ seconds = macTime / 1e9;
428
+ }
429
+
430
+ // Convert to Unix timestamp (milliseconds)
431
+ return (seconds + MAC_ABSOLUTE_EPOCH) * 1000;
432
+ }
package/package.json ADDED
@@ -0,0 +1,116 @@
1
+ {
2
+ "name": "apple-tools-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "apple-tools-mcp": "./index.js"
9
+ },
10
+ "author": "Peter Coates",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/sfls1397/apple-tools-mcp.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/sfls1397/apple-tools-mcp/issues"
18
+ },
19
+ "homepage": "https://github.com/sfls1397/apple-tools-mcp#readme",
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "apple",
24
+ "mail",
25
+ "email",
26
+ "calendar",
27
+ "messages",
28
+ "imessage",
29
+ "semantic-search",
30
+ "claude",
31
+ "anthropic",
32
+ "macos"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "files": [
38
+ "index.js",
39
+ "indexer.js",
40
+ "search.js",
41
+ "contacts.js",
42
+ "lib/",
43
+ "scripts/audit-index.js",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "start": "node index.js",
49
+ "build-index": "APPLE_TOOLS_INDEX_DAYS_BACK=30 node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
50
+ "audit": "APPLE_TOOLS_INDEX_DAYS_BACK=30 node scripts/audit-index.js",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "test:coverage": "vitest run --coverage",
54
+ "test:unit": "vitest run tests/unit",
55
+ "test:integration": "vitest run tests/integration",
56
+ "test:integration:periodic": "vitest run tests/integration/periodic-indexing-e2e.test.js --reporter=verbose",
57
+ "test:perf": "vitest run tests/performance",
58
+ "test:fuzz": "vitest run tests/fuzz",
59
+ "test:chaos": "vitest run tests/chaos",
60
+ "test:contract": "vitest run tests/contract",
61
+ "test:concurrency": "vitest run tests/concurrency",
62
+ "test:recovery": "vitest run tests/recovery",
63
+ "test:timezone": "vitest run tests/timezone",
64
+ "test:stress": "vitest run tests/stress",
65
+ "test:snapshot": "vitest run tests/snapshots",
66
+ "test:all": "vitest run",
67
+ "test:idx": "vitest run tests/indexing",
68
+ "test:idx:unit": "vitest run tests/indexing/unit",
69
+ "test:idx:integration": "vitest run tests/indexing/integration",
70
+ "test:idx:accuracy": "vitest run tests/indexing/accuracy",
71
+ "test:idx:perf": "vitest run tests/indexing/performance",
72
+ "test:idx:resource": "vitest run tests/indexing/resource",
73
+ "test:idx:edge": "vitest run tests/indexing/edge-cases",
74
+ "test:idx:security": "vitest run tests/indexing/security",
75
+ "test:idx:contacts": "vitest run tests/indexing/contacts",
76
+ "test:idx:cache": "vitest run tests/indexing/caching",
77
+ "test:idx:negative": "vitest run tests/indexing/negative",
78
+ "test:idx:watch": "vitest tests/indexing",
79
+ "test:idx:build": "node scripts/build-test-index.js",
80
+ "test:idx:clean": "node scripts/clean-test-index.js",
81
+ "perf": "USE_REAL_DATA=1 vitest run tests/perf --reporter=verbose",
82
+ "perf:mock": "vitest run tests/perf --reporter=verbose",
83
+ "perf:watch": "USE_REAL_DATA=1 vitest tests/perf",
84
+ "perf:indexing": "vitest run tests/perf/indexing.perf.test.js --reporter=verbose",
85
+ "perf:search": "vitest run tests/perf/search.perf.test.js --reporter=verbose",
86
+ "perf:tools": "vitest run tests/perf/tools.perf.test.js --reporter=verbose",
87
+ "perf:server": "vitest run tests/perf/mcp-server.perf.test.js --reporter=verbose",
88
+ "perf:embedding": "vitest run tests/perf/embedding.perf.test.js --reporter=verbose",
89
+ "perf:datasources": "vitest run tests/perf/datasources.perf.test.js --reporter=verbose",
90
+ "perf:memory": "vitest run tests/perf/memory.perf.test.js --reporter=verbose",
91
+ "perf:stress": "vitest run tests/perf/stress.perf.test.js --reporter=verbose --testTimeout=120000",
92
+ "perf:mail": "vitest run tests/perf/datasources.perf.test.js -t 'Email' --reporter=verbose",
93
+ "perf:messages": "vitest run tests/perf/datasources.perf.test.js -t 'Messages' --reporter=verbose",
94
+ "perf:calendar": "vitest run tests/perf/datasources.perf.test.js -t 'Calendar' --reporter=verbose",
95
+ "perf:contacts": "vitest run tests/perf/datasources.perf.test.js -t 'Contacts' --reporter=verbose",
96
+ "perf:quick": "vitest run tests/perf/tools.perf.test.js tests/perf/search.perf.test.js --reporter=verbose",
97
+ "perf:negative": "vitest run tests/perf/negative.perf.test.js --reporter=verbose",
98
+ "perf:edge-cases": "vitest run tests/perf/edge-cases.perf.test.js --reporter=verbose",
99
+ "perf:regression": "vitest run tests/perf/regression.perf.test.js --reporter=verbose",
100
+ "perf:lancedb": "vitest run tests/perf/lancedb.perf.test.js --reporter=verbose",
101
+ "perf:background": "vitest run tests/perf/background-indexing.perf.test.js --reporter=verbose",
102
+ "perf:dates": "vitest run tests/perf/date-parsing.perf.test.js --reporter=verbose",
103
+ "perf:full": "vitest run tests/perf --reporter=verbose --testTimeout=120000"
104
+ },
105
+ "dependencies": {
106
+ "@lancedb/lancedb": "^0.22.3",
107
+ "@modelcontextprotocol/sdk": "^1.0.0",
108
+ "@xenova/transformers": "^2.17.2",
109
+ "chrono-node": "^2.9.0"
110
+ },
111
+ "devDependencies": {
112
+ "@vitest/coverage-v8": "^4.0.14",
113
+ "fast-check": "^3.15.0",
114
+ "vitest": "^4.0.14"
115
+ }
116
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI Script for Auditing Index
5
+ *
6
+ * Usage:
7
+ * node scripts/audit-index.js
8
+ * node scripts/audit-index.js --sources emails,messages
9
+ * node scripts/audit-index.js --max-items 50
10
+ * node scripts/audit-index.js --sources calendar --max-items 0
11
+ */
12
+
13
+ import { auditAll, formatAuditReport } from '../lib/audit.js';
14
+
15
+ // Parse command-line arguments
16
+ function parseArgs() {
17
+ const args = process.argv.slice(2);
18
+ const options = {
19
+ sources: ['emails', 'messages', 'calendar'],
20
+ maxItems: 100
21
+ };
22
+
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+
26
+ if (arg === '--sources' && i + 1 < args.length) {
27
+ options.sources = args[i + 1].split(',').map(s => s.trim());
28
+ i++;
29
+ } else if (arg === '--max-items' && i + 1 < args.length) {
30
+ options.maxItems = parseInt(args[i + 1], 10);
31
+ i++;
32
+ } else if (arg === '--help' || arg === '-h') {
33
+ printHelp();
34
+ process.exit(0);
35
+ }
36
+ }
37
+
38
+ return options;
39
+ }
40
+
41
+ function printHelp() {
42
+ console.log(`
43
+ Audit Index - Check index integrity against source data
44
+
45
+ Usage:
46
+ npm run audit
47
+ npm run audit -- --sources emails,messages
48
+ npm run audit -- --max-items 50
49
+ npm run audit -- --sources calendar --max-items 0
50
+
51
+ Options:
52
+ --sources <list> Comma-separated list of sources to audit
53
+ Options: emails, messages, calendar
54
+ Default: emails,messages,calendar
55
+
56
+ --max-items <num> Maximum items to show per discrepancy category
57
+ Use 0 for unlimited
58
+ Default: 100
59
+
60
+ --help, -h Show this help message
61
+
62
+ Examples:
63
+ npm run audit # Audit all sources
64
+ npm run audit -- --sources emails # Audit emails only
65
+ npm run audit -- --sources emails,messages # Audit emails and messages
66
+ npm run audit -- --max-items 50 # Show max 50 items per category
67
+ npm run audit -- --sources calendar --max-items 0 # Show all calendar discrepancies
68
+ `);
69
+ }
70
+
71
+ // Main execution
72
+ async function main() {
73
+ try {
74
+ const options = parseArgs();
75
+
76
+ console.error('Starting index audit...');
77
+ console.error(`Sources: ${options.sources.join(', ')}`);
78
+ console.error(`Max items per category: ${options.maxItems === 0 ? 'unlimited' : options.maxItems}`);
79
+ console.error('');
80
+
81
+ const results = await auditAll(options);
82
+ const report = formatAuditReport(results);
83
+
84
+ // Print report to stdout
85
+ console.log(report);
86
+
87
+ } catch (error) {
88
+ console.error('Error running audit:', error.message);
89
+ console.error(error.stack);
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ main();