@nathanvale/chatline 0.0.1 → 0.0.2-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/bin/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/cli/commands/clean.d.ts +0 -17
- package/dist/cli/commands/clean.d.ts.map +0 -1
- package/dist/cli/commands/clean.js +0 -142
- package/dist/cli/commands/clean.js.map +0 -1
- package/dist/cli/commands/doctor.d.ts +0 -17
- package/dist/cli/commands/doctor.d.ts.map +0 -1
- package/dist/cli/commands/doctor.js +0 -202
- package/dist/cli/commands/doctor.js.map +0 -1
- package/dist/cli/commands/enrich-ai.d.ts +0 -17
- package/dist/cli/commands/enrich-ai.d.ts.map +0 -1
- package/dist/cli/commands/enrich-ai.js +0 -371
- package/dist/cli/commands/enrich-ai.js.map +0 -1
- package/dist/cli/commands/index.d.ts +0 -16
- package/dist/cli/commands/index.d.ts.map +0 -1
- package/dist/cli/commands/index.js +0 -16
- package/dist/cli/commands/index.js.map +0 -1
- package/dist/cli/commands/ingest-csv.d.ts +0 -17
- package/dist/cli/commands/ingest-csv.d.ts.map +0 -1
- package/dist/cli/commands/ingest-csv.js +0 -138
- package/dist/cli/commands/ingest-csv.js.map +0 -1
- package/dist/cli/commands/ingest-db.d.ts +0 -17
- package/dist/cli/commands/ingest-db.d.ts.map +0 -1
- package/dist/cli/commands/ingest-db.js +0 -159
- package/dist/cli/commands/ingest-db.js.map +0 -1
- package/dist/cli/commands/init.d.ts +0 -17
- package/dist/cli/commands/init.d.ts.map +0 -1
- package/dist/cli/commands/init.js +0 -110
- package/dist/cli/commands/init.js.map +0 -1
- package/dist/cli/commands/normalize-link.d.ts +0 -16
- package/dist/cli/commands/normalize-link.d.ts.map +0 -1
- package/dist/cli/commands/normalize-link.js +0 -144
- package/dist/cli/commands/normalize-link.js.map +0 -1
- package/dist/cli/commands/render-markdown.d.ts +0 -17
- package/dist/cli/commands/render-markdown.d.ts.map +0 -1
- package/dist/cli/commands/render-markdown.js +0 -218
- package/dist/cli/commands/render-markdown.js.map +0 -1
- package/dist/cli/commands/stats.d.ts +0 -17
- package/dist/cli/commands/stats.d.ts.map +0 -1
- package/dist/cli/commands/stats.js +0 -175
- package/dist/cli/commands/stats.js.map +0 -1
- package/dist/cli/commands/validate.d.ts +0 -17
- package/dist/cli/commands/validate.d.ts.map +0 -1
- package/dist/cli/commands/validate.js +0 -152
- package/dist/cli/commands/validate.js.map +0 -1
- package/dist/cli/index.d.ts +0 -13
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -121
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/types.d.ts +0 -93
- package/dist/cli/types.d.ts.map +0 -1
- package/dist/cli/types.js +0 -7
- package/dist/cli/types.js.map +0 -1
- package/dist/cli/utils.d.ts +0 -29
- package/dist/cli/utils.d.ts.map +0 -1
- package/dist/cli/utils.js +0 -53
- package/dist/cli/utils.js.map +0 -1
- package/dist/cli.d.ts +0 -9
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -1805
- package/dist/config/generator.d.ts +0 -90
- package/dist/config/generator.d.ts.map +0 -1
- package/dist/config/generator.js +0 -320
- package/dist/config/generator.js.map +0 -1
- package/dist/config/loader.d.ts +0 -107
- package/dist/config/loader.d.ts.map +0 -1
- package/dist/config/loader.js +0 -251
- package/dist/config/loader.js.map +0 -1
- package/dist/config/schema.d.ts +0 -107
- package/dist/config/schema.d.ts.map +0 -1
- package/dist/config/schema.js +0 -169
- package/dist/config/schema.js.map +0 -1
- package/dist/enrich/audio-transcription.d.ts +0 -77
- package/dist/enrich/audio-transcription.d.ts.map +0 -1
- package/dist/enrich/audio-transcription.js +0 -370
- package/dist/enrich/audio-transcription.js.map +0 -1
- package/dist/enrich/checkpoint.d.ts +0 -137
- package/dist/enrich/checkpoint.d.ts.map +0 -1
- package/dist/enrich/checkpoint.js +0 -205
- package/dist/enrich/checkpoint.js.map +0 -1
- package/dist/enrich/idempotency.d.ts +0 -90
- package/dist/enrich/idempotency.d.ts.map +0 -1
- package/dist/enrich/idempotency.js +0 -188
- package/dist/enrich/idempotency.js.map +0 -1
- package/dist/enrich/image-analysis.d.ts +0 -62
- package/dist/enrich/image-analysis.d.ts.map +0 -1
- package/dist/enrich/image-analysis.js +0 -264
- package/dist/enrich/image-analysis.js.map +0 -1
- package/dist/enrich/index.d.ts +0 -60
- package/dist/enrich/index.d.ts.map +0 -1
- package/dist/enrich/index.js +0 -74
- package/dist/enrich/index.js.map +0 -1
- package/dist/enrich/link-enrichment.d.ts +0 -37
- package/dist/enrich/link-enrichment.d.ts.map +0 -1
- package/dist/enrich/link-enrichment.js +0 -202
- package/dist/enrich/link-enrichment.js.map +0 -1
- package/dist/enrich/pdf-video-handling.d.ts +0 -49
- package/dist/enrich/pdf-video-handling.d.ts.map +0 -1
- package/dist/enrich/pdf-video-handling.js +0 -325
- package/dist/enrich/pdf-video-handling.js.map +0 -1
- package/dist/enrich/progress-tracker.d.ts +0 -120
- package/dist/enrich/progress-tracker.d.ts.map +0 -1
- package/dist/enrich/progress-tracker.js +0 -220
- package/dist/enrich/progress-tracker.js.map +0 -1
- package/dist/enrich/providers/firecrawl.d.ts +0 -18
- package/dist/enrich/providers/firecrawl.d.ts.map +0 -1
- package/dist/enrich/providers/firecrawl.js +0 -48
- package/dist/enrich/providers/firecrawl.js.map +0 -1
- package/dist/enrich/providers/generic.d.ts +0 -16
- package/dist/enrich/providers/generic.d.ts.map +0 -1
- package/dist/enrich/providers/generic.js +0 -36
- package/dist/enrich/providers/generic.js.map +0 -1
- package/dist/enrich/providers/index.d.ts +0 -14
- package/dist/enrich/providers/index.d.ts.map +0 -1
- package/dist/enrich/providers/index.js +0 -13
- package/dist/enrich/providers/index.js.map +0 -1
- package/dist/enrich/providers/instagram.d.ts +0 -16
- package/dist/enrich/providers/instagram.d.ts.map +0 -1
- package/dist/enrich/providers/instagram.js +0 -43
- package/dist/enrich/providers/instagram.js.map +0 -1
- package/dist/enrich/providers/spotify.d.ts +0 -16
- package/dist/enrich/providers/spotify.d.ts.map +0 -1
- package/dist/enrich/providers/spotify.js +0 -45
- package/dist/enrich/providers/spotify.js.map +0 -1
- package/dist/enrich/providers/twitter.d.ts +0 -16
- package/dist/enrich/providers/twitter.d.ts.map +0 -1
- package/dist/enrich/providers/twitter.js +0 -43
- package/dist/enrich/providers/twitter.js.map +0 -1
- package/dist/enrich/providers/types.d.ts +0 -47
- package/dist/enrich/providers/types.d.ts.map +0 -1
- package/dist/enrich/providers/types.js +0 -15
- package/dist/enrich/providers/types.js.map +0 -1
- package/dist/enrich/providers/youtube.d.ts +0 -16
- package/dist/enrich/providers/youtube.d.ts.map +0 -1
- package/dist/enrich/providers/youtube.js +0 -43
- package/dist/enrich/providers/youtube.js.map +0 -1
- package/dist/enrich/rate-limiting.d.ts +0 -118
- package/dist/enrich/rate-limiting.d.ts.map +0 -1
- package/dist/enrich/rate-limiting.js +0 -258
- package/dist/enrich/rate-limiting.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/ingest/dedup-merge.d.ts +0 -82
- package/dist/ingest/dedup-merge.d.ts.map +0 -1
- package/dist/ingest/dedup-merge.js +0 -262
- package/dist/ingest/dedup-merge.js.map +0 -1
- package/dist/ingest/ingest-csv.d.ts +0 -62
- package/dist/ingest/ingest-csv.d.ts.map +0 -1
- package/dist/ingest/ingest-csv.js +0 -300
- package/dist/ingest/ingest-csv.js.map +0 -1
- package/dist/ingest/ingest-db.d.ts +0 -64
- package/dist/ingest/ingest-db.d.ts.map +0 -1
- package/dist/ingest/ingest-db.js +0 -172
- package/dist/ingest/ingest-db.js.map +0 -1
- package/dist/ingest/link-replies-and-tapbacks.d.ts +0 -53
- package/dist/ingest/link-replies-and-tapbacks.d.ts.map +0 -1
- package/dist/ingest/link-replies-and-tapbacks.js +0 -381
- package/dist/ingest/link-replies-and-tapbacks.js.map +0 -1
- package/dist/normalize/date-converters.d.ts +0 -45
- package/dist/normalize/date-converters.d.ts.map +0 -1
- package/dist/normalize/date-converters.js +0 -166
- package/dist/normalize/date-converters.js.map +0 -1
- package/dist/normalize/path-validator.d.ts +0 -65
- package/dist/normalize/path-validator.d.ts.map +0 -1
- package/dist/normalize/path-validator.js +0 -221
- package/dist/normalize/path-validator.js.map +0 -1
- package/dist/normalize/validate-normalized.d.ts +0 -45
- package/dist/normalize/validate-normalized.d.ts.map +0 -1
- package/dist/normalize/validate-normalized.js +0 -144
- package/dist/normalize/validate-normalized.js.map +0 -1
- package/dist/render/embeds-blockquotes.d.ts +0 -84
- package/dist/render/embeds-blockquotes.d.ts.map +0 -1
- package/dist/render/embeds-blockquotes.js +0 -204
- package/dist/render/embeds-blockquotes.js.map +0 -1
- package/dist/render/grouping.d.ts +0 -78
- package/dist/render/grouping.d.ts.map +0 -1
- package/dist/render/grouping.js +0 -134
- package/dist/render/grouping.js.map +0 -1
- package/dist/render/index.d.ts +0 -47
- package/dist/render/index.d.ts.map +0 -1
- package/dist/render/index.js +0 -245
- package/dist/render/index.js.map +0 -1
- package/dist/render/reply-rendering.d.ts +0 -88
- package/dist/render/reply-rendering.d.ts.map +0 -1
- package/dist/render/reply-rendering.js +0 -196
- package/dist/render/reply-rendering.js.map +0 -1
- package/dist/schema/message.d.ts +0 -125
- package/dist/schema/message.d.ts.map +0 -1
- package/dist/schema/message.js +0 -331
- package/dist/schema/message.js.map +0 -1
- package/dist/utils/delta-detection.d.ts +0 -107
- package/dist/utils/delta-detection.d.ts.map +0 -1
- package/dist/utils/delta-detection.js +0 -199
- package/dist/utils/delta-detection.js.map +0 -1
- package/dist/utils/enrichment-merge.d.ts +0 -135
- package/dist/utils/enrichment-merge.d.ts.map +0 -1
- package/dist/utils/enrichment-merge.js +0 -280
- package/dist/utils/enrichment-merge.js.map +0 -1
- package/dist/utils/human.d.ts +0 -15
- package/dist/utils/human.d.ts.map +0 -1
- package/dist/utils/human.js +0 -27
- package/dist/utils/human.js.map +0 -1
- package/dist/utils/incremental-state.d.ts +0 -133
- package/dist/utils/incremental-state.d.ts.map +0 -1
- package/dist/utils/incremental-state.js +0 -237
- package/dist/utils/incremental-state.js.map +0 -1
- package/dist/utils/logger.d.ts +0 -40
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js +0 -176
- package/dist/utils/logger.js.map +0 -1
package/dist/cli.js
DELETED
|
@@ -1,1805 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* iMessage Timeline CLI
|
|
4
|
-
*
|
|
5
|
-
* Main entry point for the CLI using commander.js.
|
|
6
|
-
* Implements CLI--T01: Setup Commander.js Structure
|
|
7
|
-
*/
|
|
8
|
-
import { readFileSync } from 'node:fs';
|
|
9
|
-
import { dirname, resolve } from 'node:path';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { Command } from 'commander';
|
|
12
|
-
import { humanError, humanInfo, humanWarn, setHumanLoggingEnabled, } from '#utils/human';
|
|
13
|
-
import { createLogger, setCorrelationId, setLogLevel } from '#utils/logger';
|
|
14
|
-
// Get package.json for version
|
|
15
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
-
const __dirname = dirname(__filename);
|
|
17
|
-
const packageJsonPath = resolve(__dirname, '../package.json');
|
|
18
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
19
|
-
// ============================================================================
|
|
20
|
-
// CLI--T01-AC02: Create main program with version, description, global options
|
|
21
|
-
// ============================================================================
|
|
22
|
-
const program = new Command();
|
|
23
|
-
const cliLogger = createLogger('cli');
|
|
24
|
-
function applyLogLevel(verbose, quiet) {
|
|
25
|
-
const level = quiet ? 'error' : verbose ? 'debug' : 'info';
|
|
26
|
-
setLogLevel(level);
|
|
27
|
-
}
|
|
28
|
-
// Exported for potential test usage
|
|
29
|
-
function logEvent(event, meta) {
|
|
30
|
-
cliLogger.info(event, meta);
|
|
31
|
-
}
|
|
32
|
-
program
|
|
33
|
-
.name('imessage-timeline')
|
|
34
|
-
.version(packageJson.version)
|
|
35
|
-
.description('Extract, transform, and analyze iMessage conversations with AI-powered enrichment and timeline rendering');
|
|
36
|
-
// ============================================================================
|
|
37
|
-
// CLI--T01-AC03: Global options: --verbose, --quiet, --config <path>
|
|
38
|
-
// ============================================================================
|
|
39
|
-
program
|
|
40
|
-
.option('-v, --verbose', 'enable verbose logging', false)
|
|
41
|
-
.option('-q, --quiet', 'suppress non-error output', false)
|
|
42
|
-
.option('-c, --config <path>', 'path to config file', 'imessage-config.json')
|
|
43
|
-
.option('--json', 'emit structured JSON log events only (machine-readable)', false);
|
|
44
|
-
// Toggle human logging before each command action
|
|
45
|
-
program.hook('preAction', () => {
|
|
46
|
-
const opts = program.opts();
|
|
47
|
-
const envJson = String(process.env.IMESSAGE_JSON || '').toLowerCase();
|
|
48
|
-
const envJsonOnly = ['1', 'true', 'yes', 'y', 'json', 'json-only'].includes(envJson);
|
|
49
|
-
const jsonOnly = Boolean(opts.json) || envJsonOnly;
|
|
50
|
-
setHumanLoggingEnabled(!jsonOnly);
|
|
51
|
-
});
|
|
52
|
-
// ============================================================================
|
|
53
|
-
// CLI--T01-AC05: Top-level error handler with user-friendly messages
|
|
54
|
-
// ============================================================================
|
|
55
|
-
program.configureOutput({
|
|
56
|
-
outputError: (str, write) => {
|
|
57
|
-
const errorMsg = str
|
|
58
|
-
.replace(/^error: /, '❌ Error: ')
|
|
59
|
-
.replace(/^Error: /, '❌ Error: ');
|
|
60
|
-
write(errorMsg);
|
|
61
|
-
cliLogger.error('Commander output error', { raw: str });
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
// Custom error handler for better error messages
|
|
65
|
-
program.exitOverride((err) => {
|
|
66
|
-
// Allow help and version to exit cleanly
|
|
67
|
-
if (err.code === 'commander.help' || err.code === 'commander.helpDisplayed') {
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
70
|
-
if (err.code === 'commander.version') {
|
|
71
|
-
process.exit(0);
|
|
72
|
-
}
|
|
73
|
-
// Handle missing required options
|
|
74
|
-
if (err.code === 'commander.missingArgument') {
|
|
75
|
-
humanError(`❌ Error: ${err.message}`);
|
|
76
|
-
humanError(`\nRun 'imessage-timeline ${program.args[0] || ''} --help' for usage information`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
// Handle unknown commands
|
|
80
|
-
if (err.code === 'commander.unknownCommand') {
|
|
81
|
-
humanError(`❌ Error: ${err.message}`);
|
|
82
|
-
humanError(`\nRun 'imessage-timeline --help' to see available commands`);
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
// Generic error handler
|
|
86
|
-
humanError(`❌ Error: ${err.message}`);
|
|
87
|
-
cliLogger.error('CLI exit override error', {
|
|
88
|
-
code: err.code,
|
|
89
|
-
message: err.message,
|
|
90
|
-
});
|
|
91
|
-
process.exit(err.exitCode || 1);
|
|
92
|
-
});
|
|
93
|
-
// ============================================================================
|
|
94
|
-
// Placeholder Commands (to be implemented in CLI--T02, CLI--T03, CLI--T04, CLI--T05)
|
|
95
|
-
// ============================================================================
|
|
96
|
-
// ============================================================================
|
|
97
|
-
// Ingest commands (CLI--T02)
|
|
98
|
-
// ============================================================================
|
|
99
|
-
program
|
|
100
|
-
.command('ingest-csv')
|
|
101
|
-
.description('Import messages from iMazing CSV export')
|
|
102
|
-
.requiredOption('-i, --input <path>', 'path to CSV file')
|
|
103
|
-
.option('-o, --output <path>', 'output JSON file path', './messages.csv.ingested.json')
|
|
104
|
-
.option('-a, --attachments <dir...>', 'attachment root directories')
|
|
105
|
-
.action(async (options) => {
|
|
106
|
-
const { input, output, attachments } = options;
|
|
107
|
-
const verbose = program.opts().verbose;
|
|
108
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
109
|
-
setCorrelationId(`ingest-csv:${Date.now().toString(36)}`);
|
|
110
|
-
logEvent('ingest-start', {
|
|
111
|
-
command: 'ingest-csv',
|
|
112
|
-
phase: 'start',
|
|
113
|
-
options: { input, output, attachmentsCount: attachments?.length },
|
|
114
|
-
});
|
|
115
|
-
try {
|
|
116
|
-
// CLI-T02-AC04: Input file validation with clear error messages
|
|
117
|
-
const fs = await import('node:fs');
|
|
118
|
-
if (!fs.existsSync(input)) {
|
|
119
|
-
humanError(`❌ Input CSV file not found: ${input}`);
|
|
120
|
-
humanError('\nPlease check:');
|
|
121
|
-
humanError(' • File path is correct');
|
|
122
|
-
humanError(' • File exists and is readable');
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
// CLI-T02-AC03: Attachment root validation (check directories exist)
|
|
126
|
-
const attachmentRoots = [];
|
|
127
|
-
if (attachments && attachments.length > 0) {
|
|
128
|
-
for (const dir of attachments) {
|
|
129
|
-
if (!fs.existsSync(dir)) {
|
|
130
|
-
humanError(`❌ Attachment directory not found: ${dir}`);
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
if (!fs.statSync(dir).isDirectory()) {
|
|
134
|
-
humanError(`❌ Not a directory: ${dir}`);
|
|
135
|
-
process.exit(1);
|
|
136
|
-
}
|
|
137
|
-
attachmentRoots.push(dir);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
// Default: ~/Library/Messages/Attachments
|
|
142
|
-
const os = await import('node:os');
|
|
143
|
-
const path = await import('node:path');
|
|
144
|
-
const defaultRoot = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
|
|
145
|
-
if (fs.existsSync(defaultRoot)) {
|
|
146
|
-
attachmentRoots.push(defaultRoot);
|
|
147
|
-
if (verbose) {
|
|
148
|
-
cliLogger.info('Using default attachment root', { defaultRoot });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// CLI-T02-AC01: ingest-csv command with all options from usage guide
|
|
153
|
-
const { ingestCSV, createExportEnvelope, validateMessages } = await import('./ingest/ingest-csv.js');
|
|
154
|
-
if (verbose) {
|
|
155
|
-
cliLogger.info('Reading CSV ingest', { input, attachmentRoots });
|
|
156
|
-
}
|
|
157
|
-
const messages = ingestCSV(input, { attachmentRoots });
|
|
158
|
-
// CLI-T02-AC05: Progress output: ✓ Parsed 2,847 messages from CSV
|
|
159
|
-
humanInfo(`✓ Parsed ${messages.length.toLocaleString()} messages from CSV`);
|
|
160
|
-
// Validate messages before writing
|
|
161
|
-
const validation = validateMessages(messages);
|
|
162
|
-
if (!validation.valid) {
|
|
163
|
-
humanError(`❌ ${validation.errors.length} messages failed validation`);
|
|
164
|
-
if (verbose) {
|
|
165
|
-
validation.errors.slice(0, 5).forEach((err) => {
|
|
166
|
-
humanError(` Message ${err.index}:`, err.issues);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
// Write export envelope
|
|
172
|
-
const envelope = createExportEnvelope(messages);
|
|
173
|
-
fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
|
|
174
|
-
humanInfo(`✓ Wrote ${messages.length.toLocaleString()} messages to ${output}`);
|
|
175
|
-
humanInfo('\n📊 Summary:');
|
|
176
|
-
const textCount = messages.filter((m) => m.messageKind === 'text').length;
|
|
177
|
-
const mediaCount = messages.filter((m) => m.messageKind === 'media').length;
|
|
178
|
-
const notifCount = messages.filter((m) => m.messageKind === 'notification').length;
|
|
179
|
-
humanInfo(` Text: ${textCount}`);
|
|
180
|
-
humanInfo(` Media: ${mediaCount}`);
|
|
181
|
-
humanInfo(` Notifications: ${notifCount}`);
|
|
182
|
-
logEvent('ingest-summary', {
|
|
183
|
-
command: 'ingest-csv',
|
|
184
|
-
phase: 'summary',
|
|
185
|
-
metrics: {
|
|
186
|
-
total: messages.length,
|
|
187
|
-
text: textCount,
|
|
188
|
-
media: mediaCount,
|
|
189
|
-
notifications: notifCount,
|
|
190
|
-
},
|
|
191
|
-
options: { output },
|
|
192
|
-
exitCode: 0,
|
|
193
|
-
});
|
|
194
|
-
process.exit(0);
|
|
195
|
-
}
|
|
196
|
-
catch (error) {
|
|
197
|
-
humanError('❌ Failed to ingest CSV:', error instanceof Error ? error.message : String(error));
|
|
198
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
199
|
-
humanError(error.stack);
|
|
200
|
-
}
|
|
201
|
-
const errorMeta = {
|
|
202
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
203
|
-
message: error instanceof Error ? error.message : String(error),
|
|
204
|
-
...(error instanceof Error && error.stack
|
|
205
|
-
? { stack: error.stack }
|
|
206
|
-
: {}),
|
|
207
|
-
};
|
|
208
|
-
logEvent('ingest-error', {
|
|
209
|
-
command: 'ingest-csv',
|
|
210
|
-
phase: 'error',
|
|
211
|
-
error: errorMeta,
|
|
212
|
-
exitCode: 2,
|
|
213
|
-
});
|
|
214
|
-
process.exit(2);
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
program
|
|
218
|
-
.command('ingest-db')
|
|
219
|
-
.description('Import messages from macOS Messages.app database export (JSON)')
|
|
220
|
-
.requiredOption('-i, --input <path>', 'path to JSON file with DB messages')
|
|
221
|
-
.option('-o, --output <path>', 'output JSON file path', './messages.db.ingested.json')
|
|
222
|
-
.option('-a, --attachments <dir...>', 'attachment root directories')
|
|
223
|
-
.option('--contact <handle>', 'filter to specific contact handle')
|
|
224
|
-
.action(async (options) => {
|
|
225
|
-
const { input, output, attachments, contact } = options;
|
|
226
|
-
const verbose = program.opts().verbose;
|
|
227
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
228
|
-
logEvent('ingest-start', {
|
|
229
|
-
command: 'ingest-db',
|
|
230
|
-
phase: 'start',
|
|
231
|
-
options: {
|
|
232
|
-
input,
|
|
233
|
-
output,
|
|
234
|
-
attachmentsCount: attachments?.length,
|
|
235
|
-
contact,
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
try {
|
|
239
|
-
// CLI-T02-AC04: Input file validation with clear error messages
|
|
240
|
-
const fs = await import('node:fs');
|
|
241
|
-
if (!fs.existsSync(input)) {
|
|
242
|
-
humanError(`❌ Input JSON file not found: ${input}`);
|
|
243
|
-
humanError('\nPlease check:');
|
|
244
|
-
humanError(' • File path is correct');
|
|
245
|
-
humanError(' • File exists and is readable');
|
|
246
|
-
process.exit(1);
|
|
247
|
-
}
|
|
248
|
-
// CLI-T02-AC03: Attachment root validation (check directories exist)
|
|
249
|
-
const attachmentRoots = [];
|
|
250
|
-
if (attachments && attachments.length > 0) {
|
|
251
|
-
for (const dir of attachments) {
|
|
252
|
-
if (!fs.existsSync(dir)) {
|
|
253
|
-
humanError(`❌ Attachment directory not found: ${dir}`);
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
if (!fs.statSync(dir).isDirectory()) {
|
|
257
|
-
humanError(`❌ Not a directory: ${dir}`);
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
260
|
-
attachmentRoots.push(dir);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
// Default: ~/Library/Messages/Attachments
|
|
265
|
-
const os = await import('node:os');
|
|
266
|
-
const path = await import('node:path');
|
|
267
|
-
const defaultRoot = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
|
|
268
|
-
if (fs.existsSync(defaultRoot)) {
|
|
269
|
-
attachmentRoots.push(defaultRoot);
|
|
270
|
-
if (verbose) {
|
|
271
|
-
humanInfo(`Using default attachment root: ${defaultRoot}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
// CLI-T02-AC02: ingest-db command with database path and contact filtering
|
|
276
|
-
const { splitDBMessage } = await import('./ingest/ingest-db.js');
|
|
277
|
-
const { createExportEnvelope, validateMessages } = await import('./ingest/ingest-csv.js');
|
|
278
|
-
if (verbose) {
|
|
279
|
-
cliLogger.info('Reading DB export', {
|
|
280
|
-
input,
|
|
281
|
-
attachmentRoots,
|
|
282
|
-
contact,
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
// Read and parse DB export JSON
|
|
286
|
-
const content = fs.readFileSync(input, 'utf-8');
|
|
287
|
-
const dbMessages = JSON.parse(content);
|
|
288
|
-
if (!Array.isArray(dbMessages)) {
|
|
289
|
-
humanError(`❌ Expected JSON array of DB messages, got: ${typeof dbMessages}`);
|
|
290
|
-
process.exit(1);
|
|
291
|
-
}
|
|
292
|
-
// Filter by contact if specified
|
|
293
|
-
let filteredMessages = dbMessages;
|
|
294
|
-
if (contact) {
|
|
295
|
-
filteredMessages = dbMessages.filter((m) => m.handle === contact);
|
|
296
|
-
humanInfo(`✓ Filtered to ${filteredMessages.length} messages from ${contact}`);
|
|
297
|
-
}
|
|
298
|
-
// Split DB messages into Message objects
|
|
299
|
-
const messages = [];
|
|
300
|
-
filteredMessages.forEach((dbMsg, index) => {
|
|
301
|
-
const split = splitDBMessage(dbMsg, index + 1, { attachmentRoots });
|
|
302
|
-
messages.push(...split);
|
|
303
|
-
});
|
|
304
|
-
// CLI-T02-AC05: Progress output
|
|
305
|
-
humanInfo(`✓ Parsed ${messages.length.toLocaleString()} messages from DB export`);
|
|
306
|
-
// Validate messages before writing
|
|
307
|
-
const validation = validateMessages(messages);
|
|
308
|
-
if (!validation.valid) {
|
|
309
|
-
humanError(`❌ ${validation.errors.length} messages failed validation`);
|
|
310
|
-
if (verbose) {
|
|
311
|
-
validation.errors.slice(0, 5).forEach((err) => {
|
|
312
|
-
humanError(` Message ${err.index}:`, err.issues);
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
process.exit(1);
|
|
316
|
-
}
|
|
317
|
-
// Write export envelope
|
|
318
|
-
const envelope = createExportEnvelope(messages);
|
|
319
|
-
fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
|
|
320
|
-
humanInfo(`✓ Wrote ${messages.length.toLocaleString()} messages to ${output}`);
|
|
321
|
-
humanInfo('\n📊 Summary:');
|
|
322
|
-
const dbText = messages.filter((m) => m.messageKind === 'text').length;
|
|
323
|
-
const dbMedia = messages.filter((m) => m.messageKind === 'media').length;
|
|
324
|
-
humanInfo(` Text: ${dbText}`);
|
|
325
|
-
humanInfo(` Media: ${dbMedia}`);
|
|
326
|
-
logEvent('ingest-summary', {
|
|
327
|
-
command: 'ingest-db',
|
|
328
|
-
phase: 'summary',
|
|
329
|
-
metrics: { total: messages.length, text: dbText, media: dbMedia },
|
|
330
|
-
options: { output, contact },
|
|
331
|
-
exitCode: 0,
|
|
332
|
-
});
|
|
333
|
-
process.exit(0);
|
|
334
|
-
}
|
|
335
|
-
catch (error) {
|
|
336
|
-
humanError('❌ Failed to ingest DB export:', error instanceof Error ? error.message : String(error));
|
|
337
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
338
|
-
humanError(error.stack);
|
|
339
|
-
}
|
|
340
|
-
const errorMeta = {
|
|
341
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
342
|
-
message: error instanceof Error ? error.message : String(error),
|
|
343
|
-
...(error instanceof Error && error.stack
|
|
344
|
-
? { stack: error.stack }
|
|
345
|
-
: {}),
|
|
346
|
-
};
|
|
347
|
-
logEvent('ingest-error', {
|
|
348
|
-
command: 'ingest-db',
|
|
349
|
-
phase: 'error',
|
|
350
|
-
error: errorMeta,
|
|
351
|
-
options: { output, contact },
|
|
352
|
-
exitCode: 2,
|
|
353
|
-
});
|
|
354
|
-
process.exit(2);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
// Normalize command (CLI--T03-AC01)
|
|
358
|
-
program
|
|
359
|
-
.command('normalize-link')
|
|
360
|
-
.description('Deduplicate and link messages from multiple sources')
|
|
361
|
-
.requiredOption('-i, --input <files...>', 'input JSON files (CSV, DB, or both)')
|
|
362
|
-
.option('-o, --output <path>', 'output JSON file path', './messages.normalized.json')
|
|
363
|
-
.option('-m, --merge-mode <mode>', 'merge mode: exact|content|all (default: all)', 'all')
|
|
364
|
-
.action(async (options) => {
|
|
365
|
-
const { input, output, mergeMode } = options;
|
|
366
|
-
const verbose = program.opts().verbose;
|
|
367
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
368
|
-
try {
|
|
369
|
-
// Validate inputs
|
|
370
|
-
const fs = await import('node:fs');
|
|
371
|
-
const inputFiles = Array.isArray(input) ? input : [input];
|
|
372
|
-
for (const file of inputFiles) {
|
|
373
|
-
if (!fs.existsSync(file)) {
|
|
374
|
-
humanError(`❌ Input file not found: ${file}`);
|
|
375
|
-
process.exit(1);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
// CLI-T03-AC01: Validate merge mode
|
|
379
|
-
if (!['exact', 'content', 'all'].includes(mergeMode)) {
|
|
380
|
-
humanError(`❌ Invalid merge mode: ${mergeMode}`);
|
|
381
|
-
humanError('Valid modes: exact (GUID only), content (text matching), all (both)');
|
|
382
|
-
process.exit(1);
|
|
383
|
-
}
|
|
384
|
-
if (verbose) {
|
|
385
|
-
cliLogger.info('Start normalize-link', {
|
|
386
|
-
files: inputFiles.length,
|
|
387
|
-
mergeMode,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
// Load input files
|
|
391
|
-
const allMessages = [];
|
|
392
|
-
for (const file of inputFiles) {
|
|
393
|
-
const content = fs.readFileSync(file, 'utf-8');
|
|
394
|
-
const data = JSON.parse(content);
|
|
395
|
-
const messages = Array.isArray(data) ? data : data.messages || [];
|
|
396
|
-
allMessages.push(...messages);
|
|
397
|
-
humanInfo(`✓ Loaded ${messages.length} messages from ${file}`);
|
|
398
|
-
}
|
|
399
|
-
// Import normalize pipeline
|
|
400
|
-
const { linkRepliesToParents } = await import('./ingest/link-replies-and-tapbacks.js');
|
|
401
|
-
const { dedupAndMerge } = await import('./ingest/dedup-merge.js');
|
|
402
|
-
const { validateNormalizedMessages } = await import('./normalize/validate-normalized.js');
|
|
403
|
-
if (verbose) {
|
|
404
|
-
cliLogger.info('Total messages before linking', {
|
|
405
|
-
count: allMessages.length,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
// Step 1: Link replies and tapbacks
|
|
409
|
-
const linkedResult = linkRepliesToParents(allMessages, {
|
|
410
|
-
trackAmbiguous: true,
|
|
411
|
-
});
|
|
412
|
-
const linkedMessages = Array.isArray(linkedResult)
|
|
413
|
-
? linkedResult
|
|
414
|
-
: linkedResult.messages;
|
|
415
|
-
if (verbose &&
|
|
416
|
-
!Array.isArray(linkedResult) &&
|
|
417
|
-
linkedResult.ambiguousLinks) {
|
|
418
|
-
cliLogger.warn('Ambiguous reply/tapback links detected', {
|
|
419
|
-
count: linkedResult.ambiguousLinks.length,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
// Step 2: Deduplicate and merge (if multiple sources)
|
|
423
|
-
let normalizedMessages = linkedMessages;
|
|
424
|
-
if (inputFiles.length > 1) {
|
|
425
|
-
// Split messages by source for dedup-merge
|
|
426
|
-
const csvMessages = linkedMessages.filter((m) => m.exportVersion?.includes('csv') || !m.rowid);
|
|
427
|
-
const dbMessages = linkedMessages.filter((m) => m.rowid !== undefined);
|
|
428
|
-
const mergeResult = dedupAndMerge(csvMessages, dbMessages);
|
|
429
|
-
normalizedMessages = mergeResult.messages;
|
|
430
|
-
if (verbose) {
|
|
431
|
-
cliLogger.info('Merge statistics', {
|
|
432
|
-
input: mergeResult.stats.csvCount + mergeResult.stats.dbCount,
|
|
433
|
-
output: mergeResult.stats.outputCount,
|
|
434
|
-
exactMatches: mergeResult.stats.exactMatches,
|
|
435
|
-
contentMatches: mergeResult.stats.contentMatches,
|
|
436
|
-
noMatches: mergeResult.stats.noMatches,
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
// Step 3: Validate normalized messages
|
|
441
|
-
const validatedMessages = validateNormalizedMessages(normalizedMessages);
|
|
442
|
-
// Write output envelope
|
|
443
|
-
const { createExportEnvelope } = await import('./ingest/ingest-csv.js');
|
|
444
|
-
const envelope = createExportEnvelope(validatedMessages);
|
|
445
|
-
envelope.source = 'merged';
|
|
446
|
-
fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
|
|
447
|
-
humanInfo(`\n✅ Normalized ${validatedMessages.length.toLocaleString()} messages`);
|
|
448
|
-
humanInfo(`✓ Wrote to ${output}`);
|
|
449
|
-
humanInfo('\n📊 Final Summary:');
|
|
450
|
-
const nText = validatedMessages.filter((m) => m.messageKind === 'text').length;
|
|
451
|
-
const nMedia = validatedMessages.filter((m) => m.messageKind === 'media').length;
|
|
452
|
-
const nTapbacks = validatedMessages.filter((m) => m.messageKind === 'tapback').length;
|
|
453
|
-
const nNotifs = validatedMessages.filter((m) => m.messageKind === 'notification').length;
|
|
454
|
-
humanInfo(` Text: ${nText}`);
|
|
455
|
-
humanInfo(` Media: ${nMedia}`);
|
|
456
|
-
humanInfo(` Tapbacks: ${nTapbacks}`);
|
|
457
|
-
humanInfo(` Notifications: ${nNotifs}`);
|
|
458
|
-
logEvent('normalize-summary', {
|
|
459
|
-
command: 'normalize-link',
|
|
460
|
-
phase: 'summary',
|
|
461
|
-
metrics: {
|
|
462
|
-
total: validatedMessages.length,
|
|
463
|
-
text: nText,
|
|
464
|
-
media: nMedia,
|
|
465
|
-
tapbacks: nTapbacks,
|
|
466
|
-
notifications: nNotifs,
|
|
467
|
-
},
|
|
468
|
-
options: { output },
|
|
469
|
-
exitCode: 0,
|
|
470
|
-
});
|
|
471
|
-
process.exit(0);
|
|
472
|
-
}
|
|
473
|
-
catch (error) {
|
|
474
|
-
humanError('❌ Failed to normalize-link:', error instanceof Error ? error.message : String(error));
|
|
475
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
476
|
-
humanError(error.stack);
|
|
477
|
-
}
|
|
478
|
-
const errorMeta = {
|
|
479
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
480
|
-
message: error instanceof Error ? error.message : String(error),
|
|
481
|
-
...(error instanceof Error && error.stack
|
|
482
|
-
? { stack: error.stack }
|
|
483
|
-
: {}),
|
|
484
|
-
};
|
|
485
|
-
logEvent('normalize-error', {
|
|
486
|
-
command: 'normalize-link',
|
|
487
|
-
phase: 'error',
|
|
488
|
-
error: errorMeta,
|
|
489
|
-
exitCode: 2,
|
|
490
|
-
});
|
|
491
|
-
process.exit(2);
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
// Enrich command (CLI--T03-AC02, AC03, AC04, AC05)
|
|
495
|
-
program
|
|
496
|
-
.command('enrich-ai')
|
|
497
|
-
.description('Add AI-powered enrichment to media messages')
|
|
498
|
-
.requiredOption('-i, --input <path>', 'input normalized JSON file')
|
|
499
|
-
.option('-o, --output <path>', 'output JSON file path', './messages.enriched.json')
|
|
500
|
-
.option('-c, --checkpoint-dir <path>', 'checkpoint directory', './.checkpoints')
|
|
501
|
-
.option('--resume', 'resume from last checkpoint', false)
|
|
502
|
-
.option('--incremental', 'only enrich messages new since last enrichment run', false)
|
|
503
|
-
.option('--state-file <path>', 'path to incremental state file (auto-detects .imessage-state.json by default)')
|
|
504
|
-
.option('--reset-state', 'clear incremental state and enrich all messages', false)
|
|
505
|
-
.option('--force-refresh', 'force re-enrichment even if already done', false)
|
|
506
|
-
.option('--rate-limit <ms>', 'delay between API calls (milliseconds)', '1000')
|
|
507
|
-
.option('--max-retries <n>', 'max retries on API errors', '3')
|
|
508
|
-
.option('--checkpoint-interval <n>', 'write checkpoint every N items', '100')
|
|
509
|
-
.option('--enable-vision', 'enable image analysis with Gemini Vision', true)
|
|
510
|
-
.option('--enable-audio', 'enable audio transcription with Gemini Audio', true)
|
|
511
|
-
.option('--enable-links', 'enable link enrichment with Firecrawl', true)
|
|
512
|
-
.action(async (options) => {
|
|
513
|
-
const { input, output, checkpointDir, resume, incremental, stateFile: userProvidedStateFile, resetState, forceRefresh: _forceRefresh, rateLimitMs, maxRetries, checkpointInterval, enableVision, enableAudio, enableLinks, } = options;
|
|
514
|
-
const verbose = program.opts().verbose;
|
|
515
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
516
|
-
logEvent('enrich-start', {
|
|
517
|
-
command: 'enrich',
|
|
518
|
-
phase: 'start',
|
|
519
|
-
options: {
|
|
520
|
-
input,
|
|
521
|
-
output,
|
|
522
|
-
checkpointDir,
|
|
523
|
-
resume,
|
|
524
|
-
incremental,
|
|
525
|
-
stateFile: userProvidedStateFile,
|
|
526
|
-
resetState,
|
|
527
|
-
rateLimitMs,
|
|
528
|
-
maxRetries,
|
|
529
|
-
checkpointInterval,
|
|
530
|
-
enableVision,
|
|
531
|
-
enableAudio,
|
|
532
|
-
enableLinks,
|
|
533
|
-
},
|
|
534
|
-
});
|
|
535
|
-
try {
|
|
536
|
-
// Validate inputs
|
|
537
|
-
const fs = await import('node:fs');
|
|
538
|
-
if (!fs.existsSync(input)) {
|
|
539
|
-
humanError(`❌ Input file not found: ${input}`);
|
|
540
|
-
process.exit(1);
|
|
541
|
-
}
|
|
542
|
-
// Parse rate limit and retry options
|
|
543
|
-
const rateLimitDelay = Number.parseInt(rateLimitMs, 10);
|
|
544
|
-
const maxRetriesNum = Number.parseInt(maxRetries, 10);
|
|
545
|
-
const checkpointIntervalNum = Number.parseInt(checkpointInterval, 10);
|
|
546
|
-
if (Number.isNaN(rateLimitDelay) || rateLimitDelay < 0) {
|
|
547
|
-
humanError('❌ --rate-limit must be a non-negative number (milliseconds)');
|
|
548
|
-
process.exit(1);
|
|
549
|
-
}
|
|
550
|
-
if (Number.isNaN(maxRetriesNum) || maxRetriesNum < 0) {
|
|
551
|
-
humanError('❌ --max-retries must be a non-negative number');
|
|
552
|
-
process.exit(1);
|
|
553
|
-
}
|
|
554
|
-
if (Number.isNaN(checkpointIntervalNum) || checkpointIntervalNum < 1) {
|
|
555
|
-
humanError('❌ --checkpoint-interval must be a positive number');
|
|
556
|
-
process.exit(1);
|
|
557
|
-
}
|
|
558
|
-
if (verbose) {
|
|
559
|
-
cliLogger.info('Enrich config', {
|
|
560
|
-
input,
|
|
561
|
-
output,
|
|
562
|
-
checkpointDir,
|
|
563
|
-
rateLimitDelay,
|
|
564
|
-
maxRetries: maxRetriesNum,
|
|
565
|
-
checkpointInterval: checkpointIntervalNum,
|
|
566
|
-
enableVision,
|
|
567
|
-
enableAudio,
|
|
568
|
-
enableLinks,
|
|
569
|
-
incremental,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
// Create checkpoint directory if needed
|
|
573
|
-
if (!fs.existsSync(checkpointDir)) {
|
|
574
|
-
await import('node:fs/promises').then((fsp) => fsp.mkdir(checkpointDir, { recursive: true }));
|
|
575
|
-
}
|
|
576
|
-
// Load normalized messages
|
|
577
|
-
const content = fs.readFileSync(input, 'utf-8');
|
|
578
|
-
const data = JSON.parse(content);
|
|
579
|
-
const messages = Array.isArray(data) ? data : data.messages || [];
|
|
580
|
-
humanInfo(`✓ Loaded ${messages.length.toLocaleString()} messages`);
|
|
581
|
-
// Import enrichment modules
|
|
582
|
-
const { loadCheckpoint, computeConfigHash, saveCheckpoint, createCheckpoint, } = await import('./enrich/checkpoint.js');
|
|
583
|
-
// Import actual enrichment functions
|
|
584
|
-
const { analyzeImage } = await import('./enrich/image-analysis.js');
|
|
585
|
-
const { analyzeAudio } = await import('./enrich/audio-transcription.js');
|
|
586
|
-
const { enrichLinkContext } = await import('./enrich/link-enrichment.js');
|
|
587
|
-
const { createRateLimiter } = await import('./enrich/rate-limiting.js');
|
|
588
|
-
// Create rate limiter with circuit breaker
|
|
589
|
-
const rateLimiter = createRateLimiter({
|
|
590
|
-
rateLimitDelay,
|
|
591
|
-
maxRetries: maxRetriesNum,
|
|
592
|
-
circuitBreakerThreshold: 5,
|
|
593
|
-
circuitBreakerResetMs: 60000,
|
|
594
|
-
});
|
|
595
|
-
// Compute config hash for checkpoint verification (AC05: Config consistency)
|
|
596
|
-
const enrichConfig = {
|
|
597
|
-
enableVisionAnalysis: enableVision,
|
|
598
|
-
enableLinkAnalysis: enableLinks,
|
|
599
|
-
enableAudioTranscription: enableAudio,
|
|
600
|
-
rateLimitDelay,
|
|
601
|
-
maxRetries: maxRetriesNum,
|
|
602
|
-
};
|
|
603
|
-
const configHash = computeConfigHash(enrichConfig);
|
|
604
|
-
const checkpointPath = `${checkpointDir}/enrich-checkpoint-${configHash}.json`;
|
|
605
|
-
// INCREMENTAL--T04-AC02/AC03: Handle incremental state file
|
|
606
|
-
const stateFilePath = userProvidedStateFile || '.imessage-state.json';
|
|
607
|
-
const stateFileExists = fs.existsSync(stateFilePath);
|
|
608
|
-
// INCREMENTAL--T04-AC04: Handle --reset-state flag
|
|
609
|
-
if (resetState && stateFileExists) {
|
|
610
|
-
fs.unlinkSync(stateFilePath);
|
|
611
|
-
if (verbose) {
|
|
612
|
-
humanInfo(`🗑️ Reset incremental state file: ${stateFilePath}`);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
// Import incremental state module for AC02, AC03, AC04, AC05
|
|
616
|
-
const { loadIncrementalState, detectNewMessages } = await import('./utils/incremental-state.js');
|
|
617
|
-
// INCREMENTAL--T04-AC02: Auto-detect state file and load previous state
|
|
618
|
-
let previousState = null;
|
|
619
|
-
let newMessageGuids = [];
|
|
620
|
-
let newMessageCount = messages.length;
|
|
621
|
-
if (incremental && stateFileExists && !resetState) {
|
|
622
|
-
previousState = await loadIncrementalState(stateFilePath);
|
|
623
|
-
if (previousState) {
|
|
624
|
-
// Detect new messages using GUID comparison
|
|
625
|
-
const currentGuids = new Set(messages
|
|
626
|
-
.map((m) => m.guid)
|
|
627
|
-
.filter((g) => Boolean(g)));
|
|
628
|
-
newMessageGuids = detectNewMessages(currentGuids, previousState);
|
|
629
|
-
newMessageCount = newMessageGuids.length;
|
|
630
|
-
if (verbose) {
|
|
631
|
-
humanInfo(`♻️ Incremental mode: detected ${newMessageCount.toLocaleString()} new messages`);
|
|
632
|
-
humanInfo(` Total messages: ${messages.length.toLocaleString()}`);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
else if (incremental && !stateFileExists && !resetState) {
|
|
637
|
-
if (verbose) {
|
|
638
|
-
humanInfo(`♻️ Incremental mode enabled but no state file found: ${stateFilePath}`);
|
|
639
|
-
humanInfo(` Enriching all ${messages.length.toLocaleString()} messages`);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// AC05: Load checkpoint and verify config hash
|
|
643
|
-
let startIndex = 0;
|
|
644
|
-
if (resume) {
|
|
645
|
-
const checkpoint = await loadCheckpoint(checkpointPath);
|
|
646
|
-
if (checkpoint) {
|
|
647
|
-
if (checkpoint.configHash !== configHash) {
|
|
648
|
-
humanError('❌ Config has changed since last checkpoint');
|
|
649
|
-
humanError('Use --force-refresh to re-enrich or delete checkpoint file');
|
|
650
|
-
process.exit(1);
|
|
651
|
-
}
|
|
652
|
-
startIndex = checkpoint.lastProcessedIndex + 1;
|
|
653
|
-
cliLogger.info('Resuming from checkpoint', {
|
|
654
|
-
startIndex,
|
|
655
|
-
alreadyProcessed: checkpoint.totalProcessed,
|
|
656
|
-
failedItems: checkpoint.totalFailed,
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
else if (resume) {
|
|
660
|
-
humanWarn('⚠️ No checkpoint found, starting from beginning');
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
// AC02: Enrich messages with checkpoint support
|
|
664
|
-
const enrichedMessages = [];
|
|
665
|
-
let totalProcessed = 0;
|
|
666
|
-
let totalFailed = 0;
|
|
667
|
-
const failedItems = [];
|
|
668
|
-
// INCREMENTAL--T04-AC05: Show progress with new message count
|
|
669
|
-
const progressMsg = incremental && newMessageCount < messages.length
|
|
670
|
-
? `Enriching ${newMessageCount.toLocaleString()} new messages (${messages.length.toLocaleString()} total)`
|
|
671
|
-
: `Processing ${messages.length.toLocaleString()} messages`;
|
|
672
|
-
humanInfo(`\n🚀 Starting enrichment: ${progressMsg}`);
|
|
673
|
-
// Build enrichment configs
|
|
674
|
-
const geminiApiKey = process.env.GEMINI_API_KEY || '';
|
|
675
|
-
const firecrawlApiKey = process.env.FIRECRAWL_API_KEY;
|
|
676
|
-
const imageConfig = {
|
|
677
|
-
enableVisionAnalysis: enableVision ?? true,
|
|
678
|
-
geminiApiKey,
|
|
679
|
-
geminiModel: 'gemini-1.5-pro',
|
|
680
|
-
imageCacheDir: '/tmp/image-cache',
|
|
681
|
-
};
|
|
682
|
-
const audioConfig = {
|
|
683
|
-
enableAudioTranscription: enableAudio ?? true,
|
|
684
|
-
geminiApiKey,
|
|
685
|
-
geminiModel: 'gemini-1.5-pro',
|
|
686
|
-
rateLimitDelay,
|
|
687
|
-
maxRetries: maxRetriesNum,
|
|
688
|
-
};
|
|
689
|
-
const linkConfig = {
|
|
690
|
-
enableLinkAnalysis: enableLinks ?? true,
|
|
691
|
-
...(firecrawlApiKey ? { firecrawlApiKey } : {}),
|
|
692
|
-
rateLimitDelay,
|
|
693
|
-
maxRetries: maxRetriesNum,
|
|
694
|
-
};
|
|
695
|
-
// Build set of new message GUIDs for incremental filtering
|
|
696
|
-
const newGuidSet = new Set(newMessageGuids);
|
|
697
|
-
for (let i = startIndex; i < messages.length; i++) {
|
|
698
|
-
const message = messages[i];
|
|
699
|
-
try {
|
|
700
|
-
// INCREMENTAL--T04: Skip already-enriched messages in incremental mode
|
|
701
|
-
const shouldEnrich = !incremental || !previousState || newGuidSet.has(message.guid || '');
|
|
702
|
-
let enrichedMessage = message;
|
|
703
|
-
if (shouldEnrich) {
|
|
704
|
-
// Check circuit breaker before making API calls
|
|
705
|
-
if (rateLimiter.isCircuitOpen()) {
|
|
706
|
-
// Circuit is open - skip enrichment but don't fail
|
|
707
|
-
if (verbose) {
|
|
708
|
-
humanWarn(`⚠️ Circuit breaker open - skipping enrichment for message ${i}`);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
else {
|
|
712
|
-
// Apply rate limiting delay between API calls
|
|
713
|
-
const rateLimitDelayMs = rateLimiter.shouldRateLimit();
|
|
714
|
-
if (rateLimitDelayMs > 0) {
|
|
715
|
-
await new Promise((resolve) => setTimeout(resolve, rateLimitDelayMs));
|
|
716
|
-
}
|
|
717
|
-
rateLimiter.recordCall();
|
|
718
|
-
// Enrich based on message type and config
|
|
719
|
-
if (enableVision &&
|
|
720
|
-
message.messageKind === 'media' &&
|
|
721
|
-
message.media?.mediaKind === 'image') {
|
|
722
|
-
enrichedMessage = await analyzeImage(enrichedMessage, imageConfig);
|
|
723
|
-
rateLimiter.recordSuccess();
|
|
724
|
-
}
|
|
725
|
-
else if (enableAudio &&
|
|
726
|
-
message.messageKind === 'media' &&
|
|
727
|
-
message.media?.mediaKind === 'audio') {
|
|
728
|
-
enrichedMessage = await analyzeAudio(enrichedMessage, audioConfig);
|
|
729
|
-
rateLimiter.recordSuccess();
|
|
730
|
-
}
|
|
731
|
-
else if (enableLinks &&
|
|
732
|
-
message.messageKind === 'text' &&
|
|
733
|
-
message.text) {
|
|
734
|
-
enrichedMessage = await enrichLinkContext(enrichedMessage, linkConfig);
|
|
735
|
-
rateLimiter.recordSuccess();
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
enrichedMessages.push(enrichedMessage);
|
|
740
|
-
totalProcessed++;
|
|
741
|
-
// AC01: Write checkpoint at intervals
|
|
742
|
-
if ((i + 1) % checkpointIntervalNum === 0) {
|
|
743
|
-
const checkpoint = createCheckpoint({
|
|
744
|
-
lastProcessedIndex: i,
|
|
745
|
-
totalProcessed,
|
|
746
|
-
totalFailed,
|
|
747
|
-
stats: {
|
|
748
|
-
processedCount: totalProcessed,
|
|
749
|
-
failedCount: totalFailed,
|
|
750
|
-
enrichmentsByKind: {},
|
|
751
|
-
},
|
|
752
|
-
failedItems,
|
|
753
|
-
configHash,
|
|
754
|
-
});
|
|
755
|
-
await saveCheckpoint(checkpoint, checkpointPath);
|
|
756
|
-
if (verbose) {
|
|
757
|
-
cliLogger.info('Checkpoint written', { index: i + 1 });
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
catch (error) {
|
|
762
|
-
totalFailed++;
|
|
763
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
764
|
-
failedItems.push({
|
|
765
|
-
index: i,
|
|
766
|
-
guid: message.guid || 'unknown',
|
|
767
|
-
kind: message.messageKind || 'unknown',
|
|
768
|
-
error: errorMessage,
|
|
769
|
-
});
|
|
770
|
-
if (verbose) {
|
|
771
|
-
humanWarn(`⚠️ Failed to enrich message ${i}: ${errorMessage}`);
|
|
772
|
-
logEvent('enrich-item-failed', {
|
|
773
|
-
command: 'enrich',
|
|
774
|
-
phase: 'warning',
|
|
775
|
-
context: {
|
|
776
|
-
index: i,
|
|
777
|
-
guid: message.guid || 'unknown',
|
|
778
|
-
kind: message.messageKind || 'unknown',
|
|
779
|
-
},
|
|
780
|
-
error: {
|
|
781
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
782
|
-
message: errorMessage,
|
|
783
|
-
...(error instanceof Error && error.stack
|
|
784
|
-
? { stack: error.stack }
|
|
785
|
-
: {}),
|
|
786
|
-
},
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
// Write final checkpoint
|
|
792
|
-
const finalCheckpoint = createCheckpoint({
|
|
793
|
-
lastProcessedIndex: messages.length - 1,
|
|
794
|
-
totalProcessed,
|
|
795
|
-
totalFailed,
|
|
796
|
-
stats: {
|
|
797
|
-
processedCount: totalProcessed,
|
|
798
|
-
failedCount: totalFailed,
|
|
799
|
-
enrichmentsByKind: {},
|
|
800
|
-
},
|
|
801
|
-
failedItems,
|
|
802
|
-
configHash,
|
|
803
|
-
});
|
|
804
|
-
await saveCheckpoint(finalCheckpoint, checkpointPath);
|
|
805
|
-
// Write output
|
|
806
|
-
const { createExportEnvelope } = await import('./ingest/ingest-csv.js');
|
|
807
|
-
const envelope = createExportEnvelope(enrichedMessages);
|
|
808
|
-
envelope.source = 'merged';
|
|
809
|
-
fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
|
|
810
|
-
humanInfo('\n✅ Enrichment complete');
|
|
811
|
-
humanInfo(`✓ Processed: ${totalProcessed.toLocaleString()} messages`);
|
|
812
|
-
if (totalFailed > 0) {
|
|
813
|
-
humanInfo(`⚠️ Failed: ${totalFailed.toLocaleString()} messages`);
|
|
814
|
-
}
|
|
815
|
-
humanInfo(`✓ Wrote to ${output}`);
|
|
816
|
-
logEvent('enrich-summary', {
|
|
817
|
-
command: 'enrich',
|
|
818
|
-
phase: 'summary',
|
|
819
|
-
metrics: { processed: totalProcessed, failed: totalFailed },
|
|
820
|
-
options: { output, checkpointInterval: checkpointIntervalNum },
|
|
821
|
-
context: { checkpointPath },
|
|
822
|
-
exitCode: 0,
|
|
823
|
-
});
|
|
824
|
-
process.exit(0);
|
|
825
|
-
}
|
|
826
|
-
catch (error) {
|
|
827
|
-
humanError('❌ Failed to enrich:', error instanceof Error ? error.message : String(error));
|
|
828
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
829
|
-
humanError(error.stack);
|
|
830
|
-
}
|
|
831
|
-
const errorMeta = {
|
|
832
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
833
|
-
message: error instanceof Error ? error.message : String(error),
|
|
834
|
-
...(error instanceof Error && error.stack
|
|
835
|
-
? { stack: error.stack }
|
|
836
|
-
: {}),
|
|
837
|
-
};
|
|
838
|
-
logEvent('enrich-error', {
|
|
839
|
-
command: 'enrich',
|
|
840
|
-
phase: 'error',
|
|
841
|
-
error: errorMeta,
|
|
842
|
-
exitCode: 2,
|
|
843
|
-
});
|
|
844
|
-
process.exit(2);
|
|
845
|
-
}
|
|
846
|
-
});
|
|
847
|
-
// ============================================================================
|
|
848
|
-
// Render command (CLI--T04: Implement Render Command)
|
|
849
|
-
// ============================================================================
|
|
850
|
-
program
|
|
851
|
-
.command('render-markdown')
|
|
852
|
-
.description('Generate Obsidian-compatible markdown timeline files')
|
|
853
|
-
.requiredOption('-i, --input <path>', 'input enriched JSON file')
|
|
854
|
-
.option('-o, --output <dir>', 'output directory for markdown files', './timeline')
|
|
855
|
-
.option('--start-date <date>', 'render messages from this date (YYYY-MM-DD)')
|
|
856
|
-
.option('--end-date <date>', 'render messages until this date (YYYY-MM-DD)')
|
|
857
|
-
.option('--group-by-time', 'group messages by time-of-day (Morning/Afternoon/Evening)', true)
|
|
858
|
-
.option('--nested-replies', 'render replies as nested blockquotes', true)
|
|
859
|
-
.option('--max-nesting-depth <n>', 'maximum nesting depth for replies (default 10)', '10')
|
|
860
|
-
.action(async (options) => {
|
|
861
|
-
const { input, output, startDate, endDate, groupByTime, nestedReplies, maxNestingDepth, } = options;
|
|
862
|
-
const verbose = program.opts().verbose;
|
|
863
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
864
|
-
logEvent('render-start', {
|
|
865
|
-
command: 'render-markdown',
|
|
866
|
-
phase: 'start',
|
|
867
|
-
options: {
|
|
868
|
-
input,
|
|
869
|
-
output,
|
|
870
|
-
startDate,
|
|
871
|
-
endDate,
|
|
872
|
-
groupByTime,
|
|
873
|
-
nestedReplies,
|
|
874
|
-
maxNestingDepth,
|
|
875
|
-
},
|
|
876
|
-
});
|
|
877
|
-
try {
|
|
878
|
-
// CLI-T04-AC01: Date filtering validation
|
|
879
|
-
const fs = await import('node:fs');
|
|
880
|
-
if (!fs.existsSync(input)) {
|
|
881
|
-
humanError(`❌ Input file not found: ${input}`);
|
|
882
|
-
process.exit(1);
|
|
883
|
-
}
|
|
884
|
-
let startDateObj = null;
|
|
885
|
-
let endDateObj = null;
|
|
886
|
-
if (startDate) {
|
|
887
|
-
const start = new Date(startDate);
|
|
888
|
-
if (Number.isNaN(start.getTime())) {
|
|
889
|
-
humanError(`❌ Invalid start date: ${startDate} (use YYYY-MM-DD format)`);
|
|
890
|
-
process.exit(1);
|
|
891
|
-
}
|
|
892
|
-
startDateObj = start;
|
|
893
|
-
}
|
|
894
|
-
if (endDate) {
|
|
895
|
-
const end = new Date(endDate);
|
|
896
|
-
if (Number.isNaN(end.getTime())) {
|
|
897
|
-
humanError(`❌ Invalid end date: ${endDate} (use YYYY-MM-DD format)`);
|
|
898
|
-
process.exit(1);
|
|
899
|
-
}
|
|
900
|
-
// Set to end of day
|
|
901
|
-
end.setHours(23, 59, 59, 999);
|
|
902
|
-
endDateObj = end;
|
|
903
|
-
}
|
|
904
|
-
// CLI-T04-AC03: Validate max nesting depth
|
|
905
|
-
const maxNestingDepthNum = Number.parseInt(maxNestingDepth, 10);
|
|
906
|
-
if (Number.isNaN(maxNestingDepthNum) || maxNestingDepthNum < 1) {
|
|
907
|
-
humanError('❌ --max-nesting-depth must be a positive number');
|
|
908
|
-
process.exit(1);
|
|
909
|
-
}
|
|
910
|
-
if (verbose) {
|
|
911
|
-
humanInfo(`📄 Input: ${input}`);
|
|
912
|
-
humanInfo(`📁 Output directory: ${output}`);
|
|
913
|
-
if (startDateObj) {
|
|
914
|
-
humanInfo(`📅 Start date: ${startDate}`);
|
|
915
|
-
}
|
|
916
|
-
if (endDateObj) {
|
|
917
|
-
humanInfo(`📅 End date: ${endDate}`);
|
|
918
|
-
}
|
|
919
|
-
humanInfo(`⏱️ Group by time: ${groupByTime}`);
|
|
920
|
-
humanInfo(`⬅️ Nested replies: ${nestedReplies}`);
|
|
921
|
-
humanInfo(`📊 Max nesting depth: ${maxNestingDepthNum}`);
|
|
922
|
-
}
|
|
923
|
-
// Load input messages
|
|
924
|
-
const content = fs.readFileSync(input, 'utf-8');
|
|
925
|
-
const data = JSON.parse(content);
|
|
926
|
-
let messages = Array.isArray(data) ? data : data.messages || [];
|
|
927
|
-
if (verbose) {
|
|
928
|
-
humanInfo(`✓ Loaded ${messages.length.toLocaleString()} messages`);
|
|
929
|
-
}
|
|
930
|
-
// Filter by date range if specified
|
|
931
|
-
if (startDateObj || endDateObj) {
|
|
932
|
-
const filtered = messages.filter((msg) => {
|
|
933
|
-
const msgDate = new Date(msg.date);
|
|
934
|
-
if (startDateObj && msgDate < startDateObj)
|
|
935
|
-
return false;
|
|
936
|
-
if (endDateObj && msgDate > endDateObj)
|
|
937
|
-
return false;
|
|
938
|
-
return true;
|
|
939
|
-
});
|
|
940
|
-
humanInfo(`📊 Filtered to ${filtered.length.toLocaleString()} messages in date range`);
|
|
941
|
-
logEvent('render-filtered', {
|
|
942
|
-
command: 'render-markdown',
|
|
943
|
-
phase: 'progress',
|
|
944
|
-
metrics: { filtered: filtered.length, original: messages.length },
|
|
945
|
-
options: {
|
|
946
|
-
startDate: startDateObj ? startDateObj.toISOString() : undefined,
|
|
947
|
-
endDate: endDateObj ? endDateObj.toISOString() : undefined,
|
|
948
|
-
},
|
|
949
|
-
});
|
|
950
|
-
messages = filtered;
|
|
951
|
-
}
|
|
952
|
-
// Import render functions
|
|
953
|
-
const { renderMessages } = await import('./render/index.js');
|
|
954
|
-
// Render messages to markdown
|
|
955
|
-
const rendered = renderMessages(messages);
|
|
956
|
-
if (rendered.size === 0) {
|
|
957
|
-
humanWarn('⚠️ No messages to render');
|
|
958
|
-
process.exit(0);
|
|
959
|
-
}
|
|
960
|
-
// CLI-T04-AC04: Create output directory if doesn't exist
|
|
961
|
-
const path = await import('node:path');
|
|
962
|
-
const outputDir = path.resolve(output || './timeline');
|
|
963
|
-
if (!fs.existsSync(outputDir)) {
|
|
964
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
965
|
-
if (verbose) {
|
|
966
|
-
cliLogger.info('Created output directory', { outputDir });
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
// Write markdown files
|
|
970
|
-
let filesWritten = 0;
|
|
971
|
-
const dates = Array.from(rendered.keys()).sort();
|
|
972
|
-
for (const date of dates) {
|
|
973
|
-
const markdown = rendered.get(date);
|
|
974
|
-
if (!markdown)
|
|
975
|
-
continue;
|
|
976
|
-
const filename = `${date}.md`;
|
|
977
|
-
const filepath = path.join(outputDir, filename);
|
|
978
|
-
fs.writeFileSync(filepath, markdown, 'utf-8');
|
|
979
|
-
filesWritten++;
|
|
980
|
-
if (verbose) {
|
|
981
|
-
humanInfo(`✓ Wrote ${filename}`);
|
|
982
|
-
logEvent('render-file-written', {
|
|
983
|
-
command: 'render-markdown',
|
|
984
|
-
phase: 'progress',
|
|
985
|
-
metrics: { filesWritten },
|
|
986
|
-
context: { filename, filepath },
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
// CLI-T04-AC05: Summary output
|
|
991
|
-
humanInfo(`\n✅ Rendered ${filesWritten} markdown file(s)`);
|
|
992
|
-
humanInfo(`✓ Wrote ${filesWritten.toLocaleString()} markdown file${filesWritten === 1 ? '' : 's'} to ${outputDir}`);
|
|
993
|
-
// Message summary
|
|
994
|
-
const textMessages = messages.filter((m) => m.messageKind === 'text').length;
|
|
995
|
-
const mediaMessages = messages.filter((m) => m.messageKind === 'media').length;
|
|
996
|
-
const tapbacks = messages.filter((m) => m.messageKind === 'tapback').length;
|
|
997
|
-
humanInfo('\n📊 Message Summary:');
|
|
998
|
-
humanInfo(` Total: ${messages.length.toLocaleString()}`);
|
|
999
|
-
humanInfo(` Text: ${textMessages.toLocaleString()}`);
|
|
1000
|
-
humanInfo(` Media: ${mediaMessages.toLocaleString()}`);
|
|
1001
|
-
humanInfo(` Tapbacks: ${tapbacks.toLocaleString()}`);
|
|
1002
|
-
logEvent('render-summary', {
|
|
1003
|
-
command: 'render-markdown',
|
|
1004
|
-
phase: 'summary',
|
|
1005
|
-
metrics: {
|
|
1006
|
-
filesWritten,
|
|
1007
|
-
totalMessages: messages.length,
|
|
1008
|
-
textMessages,
|
|
1009
|
-
mediaMessages,
|
|
1010
|
-
tapbacks,
|
|
1011
|
-
},
|
|
1012
|
-
options: {
|
|
1013
|
-
outputDir,
|
|
1014
|
-
groupByTime,
|
|
1015
|
-
nestedReplies,
|
|
1016
|
-
maxNestingDepth: maxNestingDepthNum,
|
|
1017
|
-
},
|
|
1018
|
-
exitCode: 0,
|
|
1019
|
-
});
|
|
1020
|
-
process.exit(0);
|
|
1021
|
-
}
|
|
1022
|
-
catch (error) {
|
|
1023
|
-
humanError('❌ Failed to render markdown:', error instanceof Error ? error.message : String(error));
|
|
1024
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1025
|
-
humanError(error.stack);
|
|
1026
|
-
}
|
|
1027
|
-
const errorMeta = {
|
|
1028
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1029
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1030
|
-
...(error instanceof Error && error.stack
|
|
1031
|
-
? { stack: error.stack }
|
|
1032
|
-
: {}),
|
|
1033
|
-
};
|
|
1034
|
-
logEvent('render-error', {
|
|
1035
|
-
command: 'render-markdown',
|
|
1036
|
-
phase: 'error',
|
|
1037
|
-
error: errorMeta,
|
|
1038
|
-
options: { output, groupByTime, nestedReplies, maxNestingDepth },
|
|
1039
|
-
exitCode: 2,
|
|
1040
|
-
});
|
|
1041
|
-
process.exit(2);
|
|
1042
|
-
}
|
|
1043
|
-
});
|
|
1044
|
-
// ============================================================================
|
|
1045
|
-
// Helper commands (CLI--T05)
|
|
1046
|
-
// ============================================================================
|
|
1047
|
-
// CLI--T05-AC01: validate command to check JSON against schema
|
|
1048
|
-
program
|
|
1049
|
-
.command('validate')
|
|
1050
|
-
.description('Validate JSON file against message schema')
|
|
1051
|
-
.requiredOption('-i, --input <path>', 'path to JSON file to validate')
|
|
1052
|
-
.option('-q, --quiet', 'suppress detailed error messages', false)
|
|
1053
|
-
.action(async (options) => {
|
|
1054
|
-
const { input, quiet } = options;
|
|
1055
|
-
const _verbose = program.opts().verbose;
|
|
1056
|
-
applyLogLevel(_verbose, program.opts().quiet);
|
|
1057
|
-
try {
|
|
1058
|
-
const fs = await import('node:fs');
|
|
1059
|
-
// Validate input file exists
|
|
1060
|
-
if (!fs.existsSync(input)) {
|
|
1061
|
-
humanError(`❌ Input file not found: ${input}`);
|
|
1062
|
-
process.exit(1);
|
|
1063
|
-
}
|
|
1064
|
-
// Load and parse JSON
|
|
1065
|
-
const content = fs.readFileSync(input, 'utf-8');
|
|
1066
|
-
let data;
|
|
1067
|
-
try {
|
|
1068
|
-
data = JSON.parse(content);
|
|
1069
|
-
}
|
|
1070
|
-
catch (e) {
|
|
1071
|
-
humanError(`❌ Invalid JSON: ${input}`);
|
|
1072
|
-
humanError(` ${e instanceof Error ? e.message : String(e)}`);
|
|
1073
|
-
process.exit(1);
|
|
1074
|
-
}
|
|
1075
|
-
// Validate schema
|
|
1076
|
-
const { MessageSchema } = await import('./schema/message.js');
|
|
1077
|
-
const messages = Array.isArray(data)
|
|
1078
|
-
? data
|
|
1079
|
-
: data.messages || [];
|
|
1080
|
-
let validCount = 0;
|
|
1081
|
-
const errors = [];
|
|
1082
|
-
for (let i = 0; i < messages.length; i++) {
|
|
1083
|
-
const result = MessageSchema.safeParse(messages[i]);
|
|
1084
|
-
if (result.success) {
|
|
1085
|
-
validCount++;
|
|
1086
|
-
}
|
|
1087
|
-
else {
|
|
1088
|
-
result.error.errors.forEach((err) => {
|
|
1089
|
-
errors.push({
|
|
1090
|
-
index: i,
|
|
1091
|
-
path: err.path.join('.'),
|
|
1092
|
-
message: err.message,
|
|
1093
|
-
});
|
|
1094
|
-
});
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
// Output results
|
|
1098
|
-
if (!quiet) {
|
|
1099
|
-
humanInfo('📊 Validation Results:');
|
|
1100
|
-
humanInfo(` Valid: ${validCount}/${messages.length}`);
|
|
1101
|
-
}
|
|
1102
|
-
logEvent('validate-results', {
|
|
1103
|
-
command: 'validate',
|
|
1104
|
-
phase: 'progress',
|
|
1105
|
-
metrics: {
|
|
1106
|
-
valid: validCount,
|
|
1107
|
-
total: messages.length,
|
|
1108
|
-
errors: errors.length,
|
|
1109
|
-
},
|
|
1110
|
-
options: { quiet },
|
|
1111
|
-
});
|
|
1112
|
-
if (errors.length === 0) {
|
|
1113
|
-
humanInfo(`✅ All ${messages.length} messages are valid`);
|
|
1114
|
-
logEvent('validate-summary', {
|
|
1115
|
-
command: 'validate',
|
|
1116
|
-
phase: 'summary',
|
|
1117
|
-
metrics: { valid: validCount, total: messages.length },
|
|
1118
|
-
exitCode: 0,
|
|
1119
|
-
});
|
|
1120
|
-
process.exit(0);
|
|
1121
|
-
}
|
|
1122
|
-
else {
|
|
1123
|
-
humanError(`❌ ${errors.length} validation error(s) found`);
|
|
1124
|
-
logEvent('validate-error-summary', {
|
|
1125
|
-
command: 'validate',
|
|
1126
|
-
phase: 'error',
|
|
1127
|
-
metrics: {
|
|
1128
|
-
valid: validCount,
|
|
1129
|
-
total: messages.length,
|
|
1130
|
-
errors: errors.length,
|
|
1131
|
-
},
|
|
1132
|
-
exitCode: 1,
|
|
1133
|
-
});
|
|
1134
|
-
if (!quiet) {
|
|
1135
|
-
const grouped = new Map();
|
|
1136
|
-
errors.forEach((err) => {
|
|
1137
|
-
if (!grouped.has(err.index))
|
|
1138
|
-
grouped.set(err.index, []);
|
|
1139
|
-
grouped
|
|
1140
|
-
.get(err.index)
|
|
1141
|
-
.push({ path: err.path, message: err.message });
|
|
1142
|
-
});
|
|
1143
|
-
let shown = 0;
|
|
1144
|
-
grouped.forEach((errs, index) => {
|
|
1145
|
-
if (shown < 10) {
|
|
1146
|
-
humanError(`\n Message ${index}:`);
|
|
1147
|
-
errs.forEach((err) => {
|
|
1148
|
-
humanError(` ${err.path || 'root'}: ${err.message}`);
|
|
1149
|
-
shown++;
|
|
1150
|
-
if (shown >= 10)
|
|
1151
|
-
return;
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
});
|
|
1155
|
-
if (shown < errors.length) {
|
|
1156
|
-
humanError(`\n ... and ${errors.length - shown} more errors`);
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
process.exit(1);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
catch (error) {
|
|
1163
|
-
humanError('❌ Validation failed:', error instanceof Error ? error.message : String(error));
|
|
1164
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1165
|
-
humanError(error.stack);
|
|
1166
|
-
}
|
|
1167
|
-
const errorMeta = {
|
|
1168
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1169
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1170
|
-
...(error instanceof Error && error.stack
|
|
1171
|
-
? { stack: error.stack }
|
|
1172
|
-
: {}),
|
|
1173
|
-
};
|
|
1174
|
-
logEvent('validate-runtime-error', {
|
|
1175
|
-
command: 'validate',
|
|
1176
|
-
phase: 'error',
|
|
1177
|
-
error: errorMeta,
|
|
1178
|
-
exitCode: 2,
|
|
1179
|
-
});
|
|
1180
|
-
process.exit(2);
|
|
1181
|
-
}
|
|
1182
|
-
});
|
|
1183
|
-
// CLI--T05-AC02: stats command to show message counts by type
|
|
1184
|
-
program
|
|
1185
|
-
.command('stats')
|
|
1186
|
-
.description('Show statistics for message file')
|
|
1187
|
-
.requiredOption('-i, --input <path>', 'path to message JSON file')
|
|
1188
|
-
.option('-v, --verbose', 'show detailed statistics', false)
|
|
1189
|
-
.action(async (options) => {
|
|
1190
|
-
const { input } = options;
|
|
1191
|
-
const verbose = program.opts().verbose || options.verbose;
|
|
1192
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
1193
|
-
logEvent('stats-start', {
|
|
1194
|
-
command: 'stats',
|
|
1195
|
-
phase: 'start',
|
|
1196
|
-
options: { input },
|
|
1197
|
-
});
|
|
1198
|
-
try {
|
|
1199
|
-
const fs = await import('node:fs');
|
|
1200
|
-
// Validate input file exists
|
|
1201
|
-
if (!fs.existsSync(input)) {
|
|
1202
|
-
humanError(`❌ Input file not found: ${input}`);
|
|
1203
|
-
process.exit(1);
|
|
1204
|
-
}
|
|
1205
|
-
// Load and parse JSON
|
|
1206
|
-
const content = fs.readFileSync(input, 'utf-8');
|
|
1207
|
-
let data;
|
|
1208
|
-
try {
|
|
1209
|
-
data = JSON.parse(content);
|
|
1210
|
-
}
|
|
1211
|
-
catch {
|
|
1212
|
-
humanError(`❌ Invalid JSON: ${input}`);
|
|
1213
|
-
process.exit(1);
|
|
1214
|
-
}
|
|
1215
|
-
// Extract messages
|
|
1216
|
-
const messages = Array.isArray(data)
|
|
1217
|
-
? data
|
|
1218
|
-
: data.messages || [];
|
|
1219
|
-
// Count by messageKind
|
|
1220
|
-
const stats = {
|
|
1221
|
-
total: messages.length,
|
|
1222
|
-
text: 0,
|
|
1223
|
-
media: 0,
|
|
1224
|
-
tapback: 0,
|
|
1225
|
-
notification: 0,
|
|
1226
|
-
withMedia: 0,
|
|
1227
|
-
withEnrichment: 0,
|
|
1228
|
-
dateRange: { min: null, max: null },
|
|
1229
|
-
};
|
|
1230
|
-
const senders = new Set();
|
|
1231
|
-
let totalEnrichments = 0;
|
|
1232
|
-
messages.forEach((msg) => {
|
|
1233
|
-
if (msg.messageKind === 'text')
|
|
1234
|
-
stats.text++;
|
|
1235
|
-
if (msg.messageKind === 'media')
|
|
1236
|
-
stats.media++;
|
|
1237
|
-
if (msg.messageKind === 'tapback')
|
|
1238
|
-
stats.tapback++;
|
|
1239
|
-
if (msg.messageKind === 'notification')
|
|
1240
|
-
stats.notification++;
|
|
1241
|
-
if (msg.media) {
|
|
1242
|
-
stats.withMedia++;
|
|
1243
|
-
if (msg.media.enrichment && Array.isArray(msg.media.enrichment)) {
|
|
1244
|
-
stats.withEnrichment++;
|
|
1245
|
-
totalEnrichments += msg.media.enrichment.length;
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
const sender = msg.handle ?? (msg.isFromMe ? 'Me' : 'Unknown');
|
|
1249
|
-
senders.add(sender);
|
|
1250
|
-
if (msg.date) {
|
|
1251
|
-
if (!stats.dateRange.min || msg.date < stats.dateRange.min) {
|
|
1252
|
-
stats.dateRange.min = msg.date;
|
|
1253
|
-
}
|
|
1254
|
-
if (!stats.dateRange.max || msg.date > stats.dateRange.max) {
|
|
1255
|
-
stats.dateRange.max = msg.date;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
// Output summary
|
|
1260
|
-
humanInfo('📊 Message Statistics');
|
|
1261
|
-
humanInfo(`\n Total messages: ${stats.total.toLocaleString()}`);
|
|
1262
|
-
humanInfo('\n Message Types:');
|
|
1263
|
-
humanInfo(` Text: ${stats.text}`);
|
|
1264
|
-
humanInfo(` Media: ${stats.media}`);
|
|
1265
|
-
humanInfo(` Tapbacks: ${stats.tapback}`);
|
|
1266
|
-
humanInfo(` Notifications: ${stats.notification}`);
|
|
1267
|
-
humanInfo('\n Enrichment:');
|
|
1268
|
-
humanInfo(` Messages with media: ${stats.withMedia}`);
|
|
1269
|
-
humanInfo(` Messages with enrichment: ${stats.withEnrichment}`);
|
|
1270
|
-
if (stats.withEnrichment > 0) {
|
|
1271
|
-
humanInfo(` Total enrichments: ${totalEnrichments}`);
|
|
1272
|
-
humanInfo(` Avg enrichments per message: ${(totalEnrichments / stats.withEnrichment).toFixed(2)}`);
|
|
1273
|
-
}
|
|
1274
|
-
humanInfo('\n Date Range:');
|
|
1275
|
-
if (stats.dateRange.min && stats.dateRange.max) {
|
|
1276
|
-
humanInfo(` From: ${stats.dateRange.min}`);
|
|
1277
|
-
humanInfo(` To: ${stats.dateRange.max}`);
|
|
1278
|
-
const minDate = new Date(stats.dateRange.min);
|
|
1279
|
-
const maxDate = new Date(stats.dateRange.max);
|
|
1280
|
-
const days = Math.floor((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
1281
|
-
humanInfo(` Duration: ${days + 1} days`);
|
|
1282
|
-
}
|
|
1283
|
-
else {
|
|
1284
|
-
humanInfo(' None');
|
|
1285
|
-
}
|
|
1286
|
-
if (verbose) {
|
|
1287
|
-
humanInfo(`\n Participants: ${senders.size}`);
|
|
1288
|
-
if (senders.size > 0 && senders.size <= 20) {
|
|
1289
|
-
Array.from(senders)
|
|
1290
|
-
.sort()
|
|
1291
|
-
.forEach((sender) => {
|
|
1292
|
-
const count = messages.filter((m) => {
|
|
1293
|
-
const msgSender = m.handle ?? (m.isFromMe ? 'Me' : 'Unknown');
|
|
1294
|
-
return msgSender === sender;
|
|
1295
|
-
}).length;
|
|
1296
|
-
humanInfo(` ${sender}: ${count}`);
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
logEvent('stats-summary', {
|
|
1301
|
-
command: 'stats',
|
|
1302
|
-
phase: 'summary',
|
|
1303
|
-
metrics: {
|
|
1304
|
-
total: stats.total,
|
|
1305
|
-
text: stats.text,
|
|
1306
|
-
media: stats.media,
|
|
1307
|
-
tapback: stats.tapback,
|
|
1308
|
-
notification: stats.notification,
|
|
1309
|
-
withMedia: stats.withMedia,
|
|
1310
|
-
withEnrichment: stats.withEnrichment,
|
|
1311
|
-
participants: senders.size,
|
|
1312
|
-
},
|
|
1313
|
-
options: { input, verbose },
|
|
1314
|
-
exitCode: 0,
|
|
1315
|
-
});
|
|
1316
|
-
process.exit(0);
|
|
1317
|
-
}
|
|
1318
|
-
catch (error) {
|
|
1319
|
-
humanError('❌ Stats failed:', error instanceof Error ? error.message : String(error));
|
|
1320
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1321
|
-
humanError(error.stack);
|
|
1322
|
-
}
|
|
1323
|
-
const errorMeta = {
|
|
1324
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1325
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1326
|
-
...(error instanceof Error && error.stack
|
|
1327
|
-
? { stack: error.stack }
|
|
1328
|
-
: {}),
|
|
1329
|
-
};
|
|
1330
|
-
logEvent('stats-error', {
|
|
1331
|
-
command: 'stats',
|
|
1332
|
-
phase: 'error',
|
|
1333
|
-
error: errorMeta,
|
|
1334
|
-
options: { input, verbose },
|
|
1335
|
-
exitCode: 2,
|
|
1336
|
-
});
|
|
1337
|
-
process.exit(2);
|
|
1338
|
-
}
|
|
1339
|
-
});
|
|
1340
|
-
// CLI--T05-AC03: clean command to remove checkpoints and temp files
|
|
1341
|
-
program
|
|
1342
|
-
.command('clean')
|
|
1343
|
-
.description('Remove temporary files and checkpoints')
|
|
1344
|
-
.option('-c, --checkpoint-dir <path>', 'checkpoint directory to clean', './.checkpoints')
|
|
1345
|
-
.option('-f, --force', 'remove without confirmation', false)
|
|
1346
|
-
.option('--all', 'also remove backup files (.backup, .old)', false)
|
|
1347
|
-
.action(async (options) => {
|
|
1348
|
-
const { checkpointDir, force, all } = options;
|
|
1349
|
-
const verbose = program.opts().verbose;
|
|
1350
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
1351
|
-
try {
|
|
1352
|
-
const fs = await import('node:fs');
|
|
1353
|
-
const path = await import('node:path');
|
|
1354
|
-
// Check if checkpoint dir exists
|
|
1355
|
-
if (!fs.existsSync(checkpointDir)) {
|
|
1356
|
-
if (verbose) {
|
|
1357
|
-
humanInfo(`ℹ️ Checkpoint directory not found: ${checkpointDir}`);
|
|
1358
|
-
}
|
|
1359
|
-
logEvent('clean-missing-dir', {
|
|
1360
|
-
command: 'clean',
|
|
1361
|
-
phase: 'progress',
|
|
1362
|
-
options: { checkpointDir },
|
|
1363
|
-
});
|
|
1364
|
-
process.exit(0);
|
|
1365
|
-
}
|
|
1366
|
-
if (!fs.statSync(checkpointDir).isDirectory()) {
|
|
1367
|
-
humanError(`❌ Not a directory: ${checkpointDir}`);
|
|
1368
|
-
process.exit(1);
|
|
1369
|
-
}
|
|
1370
|
-
// List files to be removed
|
|
1371
|
-
const files = fs.readdirSync(checkpointDir);
|
|
1372
|
-
const toRemove = files.filter((f) => f.startsWith('enrich-checkpoint-') ||
|
|
1373
|
-
(all && (f.endsWith('.backup') || f.endsWith('.old'))));
|
|
1374
|
-
if (toRemove.length === 0) {
|
|
1375
|
-
humanInfo('ℹ️ No checkpoint files to clean');
|
|
1376
|
-
logEvent('clean-none', {
|
|
1377
|
-
command: 'clean',
|
|
1378
|
-
phase: 'summary',
|
|
1379
|
-
metrics: { removed: 0 },
|
|
1380
|
-
options: { checkpointDir },
|
|
1381
|
-
exitCode: 0,
|
|
1382
|
-
});
|
|
1383
|
-
process.exit(0);
|
|
1384
|
-
}
|
|
1385
|
-
// Show what will be removed
|
|
1386
|
-
humanInfo('♻️ Files to be removed:');
|
|
1387
|
-
toRemove.forEach((f) => {
|
|
1388
|
-
const filepath = path.join(checkpointDir, f);
|
|
1389
|
-
const size = fs.statSync(filepath).size;
|
|
1390
|
-
humanInfo(` ${f} (${(size / 1024).toFixed(1)} KB)`);
|
|
1391
|
-
});
|
|
1392
|
-
logEvent('clean-list', {
|
|
1393
|
-
command: 'clean',
|
|
1394
|
-
phase: 'progress',
|
|
1395
|
-
metrics: { count: toRemove.length },
|
|
1396
|
-
options: { checkpointDir, all },
|
|
1397
|
-
});
|
|
1398
|
-
if (!force) {
|
|
1399
|
-
humanInfo('\nRun with --force to remove these files');
|
|
1400
|
-
logEvent('clean-requires-force', {
|
|
1401
|
-
command: 'clean',
|
|
1402
|
-
phase: 'progress',
|
|
1403
|
-
options: { checkpointDir, all },
|
|
1404
|
-
});
|
|
1405
|
-
process.exit(0);
|
|
1406
|
-
}
|
|
1407
|
-
// Remove files
|
|
1408
|
-
let removed = 0;
|
|
1409
|
-
toRemove.forEach((f) => {
|
|
1410
|
-
const filepath = path.join(checkpointDir, f);
|
|
1411
|
-
try {
|
|
1412
|
-
fs.unlinkSync(filepath);
|
|
1413
|
-
removed++;
|
|
1414
|
-
if (verbose) {
|
|
1415
|
-
humanInfo(`✓ Removed ${f}`);
|
|
1416
|
-
logEvent('clean-file-removed', {
|
|
1417
|
-
command: 'clean',
|
|
1418
|
-
phase: 'progress',
|
|
1419
|
-
context: { file: f },
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
catch {
|
|
1424
|
-
humanWarn(`⚠️ Failed to remove ${f}`);
|
|
1425
|
-
logEvent('clean-file-remove-failed', {
|
|
1426
|
-
command: 'clean',
|
|
1427
|
-
phase: 'warning',
|
|
1428
|
-
context: { file: f },
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
});
|
|
1432
|
-
humanInfo(`✅ Cleaned ${removed} checkpoint file(s)`);
|
|
1433
|
-
logEvent('clean-summary', {
|
|
1434
|
-
command: 'clean',
|
|
1435
|
-
phase: 'summary',
|
|
1436
|
-
metrics: { removed },
|
|
1437
|
-
options: { checkpointDir, all },
|
|
1438
|
-
exitCode: 0,
|
|
1439
|
-
});
|
|
1440
|
-
process.exit(0);
|
|
1441
|
-
}
|
|
1442
|
-
catch (error) {
|
|
1443
|
-
humanError('❌ Clean failed:', error instanceof Error ? error.message : String(error));
|
|
1444
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1445
|
-
humanError(error.stack);
|
|
1446
|
-
}
|
|
1447
|
-
const errorMeta = {
|
|
1448
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1449
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1450
|
-
...(error instanceof Error && error.stack
|
|
1451
|
-
? { stack: error.stack }
|
|
1452
|
-
: {}),
|
|
1453
|
-
};
|
|
1454
|
-
logEvent('clean-error', {
|
|
1455
|
-
command: 'clean',
|
|
1456
|
-
phase: 'error',
|
|
1457
|
-
error: errorMeta,
|
|
1458
|
-
options: { checkpointDir, all },
|
|
1459
|
-
exitCode: 2,
|
|
1460
|
-
});
|
|
1461
|
-
process.exit(2);
|
|
1462
|
-
}
|
|
1463
|
-
});
|
|
1464
|
-
// CLI--T05-AC04: doctor command to diagnose common issues
|
|
1465
|
-
program
|
|
1466
|
-
.command('doctor')
|
|
1467
|
-
.description('Diagnose common configuration issues')
|
|
1468
|
-
.option('-v, --verbose', 'show detailed diagnostics', false)
|
|
1469
|
-
.action(async (options) => {
|
|
1470
|
-
const verbose = program.opts().verbose || options.verbose;
|
|
1471
|
-
applyLogLevel(verbose, program.opts().quiet);
|
|
1472
|
-
try {
|
|
1473
|
-
const fs = await import('node:fs');
|
|
1474
|
-
const path = await import('node:path');
|
|
1475
|
-
const os = await import('node:os');
|
|
1476
|
-
humanInfo('🔍 iMessage Timeline Diagnostics\n');
|
|
1477
|
-
logEvent('doctor-start', { command: 'doctor', phase: 'start' });
|
|
1478
|
-
const checks = [];
|
|
1479
|
-
// Check 1: Node version
|
|
1480
|
-
const nodeVersion = process.version;
|
|
1481
|
-
const nodeMajor = Number.parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
|
|
1482
|
-
const nodeOk = nodeMajor >= 18;
|
|
1483
|
-
checks.push({
|
|
1484
|
-
name: 'Node.js version',
|
|
1485
|
-
pass: nodeOk,
|
|
1486
|
-
message: `${nodeVersion} ${nodeOk ? '✓' : '(requires ≥18)'}`,
|
|
1487
|
-
});
|
|
1488
|
-
// Check 2: Current directory
|
|
1489
|
-
const cwd = process.cwd();
|
|
1490
|
-
const packageJsonExists = fs.existsSync(path.join(cwd, 'package.json'));
|
|
1491
|
-
checks.push({
|
|
1492
|
-
name: 'package.json',
|
|
1493
|
-
pass: packageJsonExists,
|
|
1494
|
-
message: packageJsonExists
|
|
1495
|
-
? `Found in ${cwd}`
|
|
1496
|
-
: 'Not found in current directory',
|
|
1497
|
-
});
|
|
1498
|
-
// Check 3: Config file
|
|
1499
|
-
const configFormats = [
|
|
1500
|
-
'imessage-config.yaml',
|
|
1501
|
-
'imessage-config.yml',
|
|
1502
|
-
'imessage-config.json',
|
|
1503
|
-
];
|
|
1504
|
-
const foundConfig = configFormats.find((f) => fs.existsSync(path.join(cwd, f)));
|
|
1505
|
-
checks.push({
|
|
1506
|
-
name: 'Config file',
|
|
1507
|
-
pass: Boolean(foundConfig),
|
|
1508
|
-
message: foundConfig
|
|
1509
|
-
? `Found: ${foundConfig}`
|
|
1510
|
-
: 'Not found (run: imessage-timeline init)',
|
|
1511
|
-
});
|
|
1512
|
-
// Check 4: API Keys
|
|
1513
|
-
const geminiKey = process.env.GEMINI_API_KEY;
|
|
1514
|
-
const firecrawlKey = process.env.FIRECRAWL_API_KEY;
|
|
1515
|
-
checks.push({
|
|
1516
|
-
name: 'GEMINI_API_KEY',
|
|
1517
|
-
pass: Boolean(geminiKey),
|
|
1518
|
-
message: geminiKey
|
|
1519
|
-
? 'Set'
|
|
1520
|
-
: 'Not set (required for image/audio enrichment)',
|
|
1521
|
-
});
|
|
1522
|
-
checks.push({
|
|
1523
|
-
name: 'FIRECRAWL_API_KEY',
|
|
1524
|
-
pass: Boolean(firecrawlKey),
|
|
1525
|
-
message: firecrawlKey
|
|
1526
|
-
? 'Set'
|
|
1527
|
-
: 'Not set (optional, improves link enrichment - get from firecrawl.dev)',
|
|
1528
|
-
});
|
|
1529
|
-
// Check 5: Default attachment directory
|
|
1530
|
-
const defaultAttachDir = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
|
|
1531
|
-
const attachDirExists = fs.existsSync(defaultAttachDir);
|
|
1532
|
-
checks.push({
|
|
1533
|
-
name: 'Messages attachments',
|
|
1534
|
-
pass: attachDirExists,
|
|
1535
|
-
message: attachDirExists
|
|
1536
|
-
? `Found: ${defaultAttachDir}`
|
|
1537
|
-
: `Not found: ${defaultAttachDir}`,
|
|
1538
|
-
});
|
|
1539
|
-
// Check 6: Output directory permission
|
|
1540
|
-
const canWrite = (() => {
|
|
1541
|
-
try {
|
|
1542
|
-
const testFile = path.join(cwd, '.test-write');
|
|
1543
|
-
fs.writeFileSync(testFile, 'test');
|
|
1544
|
-
fs.unlinkSync(testFile);
|
|
1545
|
-
return true;
|
|
1546
|
-
}
|
|
1547
|
-
catch {
|
|
1548
|
-
return false;
|
|
1549
|
-
}
|
|
1550
|
-
})();
|
|
1551
|
-
checks.push({
|
|
1552
|
-
name: 'Write permission',
|
|
1553
|
-
pass: canWrite,
|
|
1554
|
-
message: canWrite
|
|
1555
|
-
? `Can write to ${cwd}`
|
|
1556
|
-
: `Cannot write to ${cwd} (check permissions)`,
|
|
1557
|
-
});
|
|
1558
|
-
// Print results
|
|
1559
|
-
let passCount = 0;
|
|
1560
|
-
checks.forEach((check) => {
|
|
1561
|
-
const icon = check.pass ? '✅' : '⚠️ ';
|
|
1562
|
-
humanInfo(`${icon} ${check.name.padEnd(25)} ${check.message}`);
|
|
1563
|
-
if (check.pass)
|
|
1564
|
-
passCount++;
|
|
1565
|
-
logEvent('doctor-check', {
|
|
1566
|
-
command: 'doctor',
|
|
1567
|
-
phase: 'progress',
|
|
1568
|
-
context: { name: check.name },
|
|
1569
|
-
metrics: { pass: check.pass },
|
|
1570
|
-
message: check.message,
|
|
1571
|
-
});
|
|
1572
|
-
});
|
|
1573
|
-
humanInfo(`\n📊 Summary: ${passCount}/${checks.length} checks passed`);
|
|
1574
|
-
logEvent('doctor-summary', {
|
|
1575
|
-
command: 'doctor',
|
|
1576
|
-
phase: 'summary',
|
|
1577
|
-
metrics: { passed: passCount, total: checks.length },
|
|
1578
|
-
});
|
|
1579
|
-
// Recommendations
|
|
1580
|
-
const failures = checks.filter((c) => !c.pass);
|
|
1581
|
-
if (failures.length > 0) {
|
|
1582
|
-
humanInfo('\n💡 Recommendations:');
|
|
1583
|
-
failures.forEach((check) => {
|
|
1584
|
-
if (check.name === 'Config file') {
|
|
1585
|
-
humanInfo(' • Run: imessage-timeline init');
|
|
1586
|
-
}
|
|
1587
|
-
else if (check.name === 'GEMINI_API_KEY') {
|
|
1588
|
-
humanInfo(' • Get API key from: https://ai.google.dev/tutorials/setup');
|
|
1589
|
-
humanInfo(' • Set: export GEMINI_API_KEY=your_key');
|
|
1590
|
-
}
|
|
1591
|
-
else if (check.name === 'FIRECRAWL_API_KEY') {
|
|
1592
|
-
humanInfo(' • (Optional) Get from: https://www.firecrawl.dev');
|
|
1593
|
-
}
|
|
1594
|
-
logEvent('doctor-recommendation', {
|
|
1595
|
-
command: 'doctor',
|
|
1596
|
-
phase: 'progress',
|
|
1597
|
-
context: { name: check.name },
|
|
1598
|
-
message: check.message,
|
|
1599
|
-
});
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
if (verbose) {
|
|
1603
|
-
humanInfo('\n📝 Environment:');
|
|
1604
|
-
humanInfo(` Platform: ${os.platform()}`);
|
|
1605
|
-
humanInfo(` Arch: ${os.arch()}`);
|
|
1606
|
-
humanInfo(` Home: ${os.homedir()}`);
|
|
1607
|
-
humanInfo(` CWD: ${cwd}`);
|
|
1608
|
-
logEvent('doctor-environment', {
|
|
1609
|
-
command: 'doctor',
|
|
1610
|
-
phase: 'progress',
|
|
1611
|
-
context: {
|
|
1612
|
-
platform: os.platform(),
|
|
1613
|
-
arch: os.arch(),
|
|
1614
|
-
home: os.homedir(),
|
|
1615
|
-
cwd,
|
|
1616
|
-
},
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1619
|
-
logEvent('doctor-exit', {
|
|
1620
|
-
command: 'doctor',
|
|
1621
|
-
phase: 'summary',
|
|
1622
|
-
metrics: { failures: failures.length },
|
|
1623
|
-
exitCode: failures.length > 0 ? 1 : 0,
|
|
1624
|
-
});
|
|
1625
|
-
process.exit(failures.length > 0 ? 1 : 0);
|
|
1626
|
-
}
|
|
1627
|
-
catch (error) {
|
|
1628
|
-
humanError('❌ Doctor failed:', error instanceof Error ? error.message : String(error));
|
|
1629
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1630
|
-
humanError(error.stack);
|
|
1631
|
-
}
|
|
1632
|
-
const errorMeta = {
|
|
1633
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1634
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1635
|
-
...(error instanceof Error && error.stack
|
|
1636
|
-
? { stack: error.stack }
|
|
1637
|
-
: {}),
|
|
1638
|
-
};
|
|
1639
|
-
logEvent('doctor-error', {
|
|
1640
|
-
command: 'doctor',
|
|
1641
|
-
phase: 'error',
|
|
1642
|
-
error: errorMeta,
|
|
1643
|
-
exitCode: 2,
|
|
1644
|
-
});
|
|
1645
|
-
process.exit(2);
|
|
1646
|
-
}
|
|
1647
|
-
});
|
|
1648
|
-
// ============================================================================
|
|
1649
|
-
// Config generation command (CONFIG--T03)
|
|
1650
|
-
// ============================================================================
|
|
1651
|
-
program
|
|
1652
|
-
.command('init')
|
|
1653
|
-
.description('Generate starter configuration file')
|
|
1654
|
-
.option('-f, --format <type>', 'config file format (json|yaml)', 'yaml')
|
|
1655
|
-
.option('--force', 'overwrite existing config file without prompting', false)
|
|
1656
|
-
.option('-o, --output <path>', 'output file path (default: auto-detected from format)')
|
|
1657
|
-
.action(async (options) => {
|
|
1658
|
-
const { format, force, output } = options;
|
|
1659
|
-
// Validate format
|
|
1660
|
-
if (format !== 'json' && format !== 'yaml') {
|
|
1661
|
-
humanError(`❌ Invalid format: ${format}`);
|
|
1662
|
-
humanError('Supported formats: json, yaml');
|
|
1663
|
-
process.exit(1);
|
|
1664
|
-
}
|
|
1665
|
-
try {
|
|
1666
|
-
// Lazy import to avoid circular dependencies
|
|
1667
|
-
const { generateConfigFile, getDefaultConfigPath, configFileExists } = await import('./config/generator.js');
|
|
1668
|
-
// Determine output path
|
|
1669
|
-
const filePath = output || getDefaultConfigPath(format);
|
|
1670
|
-
// CONFIG-T03-AC04: Check for existing file and prompt if needed
|
|
1671
|
-
const exists = await configFileExists(filePath);
|
|
1672
|
-
if (exists && !force) {
|
|
1673
|
-
humanError(`❌ Config file already exists: ${filePath}`);
|
|
1674
|
-
humanError('\nOptions:');
|
|
1675
|
-
humanError(' • Use --force to overwrite');
|
|
1676
|
-
humanError(' • Use --output to specify different path');
|
|
1677
|
-
humanError(' • Manually remove the existing file');
|
|
1678
|
-
process.exit(1);
|
|
1679
|
-
}
|
|
1680
|
-
// CONFIG-T03-AC01, AC02, AC03: Generate config file
|
|
1681
|
-
const result = await generateConfigFile({
|
|
1682
|
-
filePath,
|
|
1683
|
-
format,
|
|
1684
|
-
force,
|
|
1685
|
-
});
|
|
1686
|
-
if (result.success) {
|
|
1687
|
-
humanInfo(result.message);
|
|
1688
|
-
humanInfo('\n📝 Next steps:');
|
|
1689
|
-
humanInfo(` 1. Edit ${filePath} to add your API keys`);
|
|
1690
|
-
humanInfo(' 2. Set GEMINI_API_KEY environment variable');
|
|
1691
|
-
humanInfo(' 3. (Optional) Set FIRECRAWL_API_KEY for enhanced link scraping');
|
|
1692
|
-
humanInfo('\n💡 See inline comments in the config file for details');
|
|
1693
|
-
logEvent('init-summary', {
|
|
1694
|
-
command: 'init',
|
|
1695
|
-
phase: 'summary',
|
|
1696
|
-
options: { format, filePath, force },
|
|
1697
|
-
message: result.message,
|
|
1698
|
-
exitCode: 0,
|
|
1699
|
-
});
|
|
1700
|
-
process.exit(0);
|
|
1701
|
-
}
|
|
1702
|
-
else {
|
|
1703
|
-
humanError(`❌ ${result.message}`);
|
|
1704
|
-
logEvent('init-error', {
|
|
1705
|
-
command: 'init',
|
|
1706
|
-
phase: 'error',
|
|
1707
|
-
options: { format, filePath, force },
|
|
1708
|
-
message: result.message,
|
|
1709
|
-
exitCode: 1,
|
|
1710
|
-
});
|
|
1711
|
-
process.exit(1);
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
catch (error) {
|
|
1715
|
-
humanError('❌ Failed to generate config:', error instanceof Error ? error.message : String(error));
|
|
1716
|
-
if (program.opts().verbose && error instanceof Error) {
|
|
1717
|
-
humanError(error.stack);
|
|
1718
|
-
}
|
|
1719
|
-
const errorMeta = {
|
|
1720
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1721
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1722
|
-
...(error instanceof Error && error.stack
|
|
1723
|
-
? { stack: error.stack }
|
|
1724
|
-
: {}),
|
|
1725
|
-
};
|
|
1726
|
-
logEvent('init-runtime-error', {
|
|
1727
|
-
command: 'init',
|
|
1728
|
-
phase: 'error',
|
|
1729
|
-
error: errorMeta,
|
|
1730
|
-
options: { format, output, force },
|
|
1731
|
-
exitCode: 2,
|
|
1732
|
-
});
|
|
1733
|
-
process.exit(2);
|
|
1734
|
-
}
|
|
1735
|
-
});
|
|
1736
|
-
// ============================================================================
|
|
1737
|
-
// CLI--T01-AC04: Proper exit codes (0=success, 1=validation, 2=runtime)
|
|
1738
|
-
// ============================================================================
|
|
1739
|
-
// Global error handler for uncaught errors (guard to prevent duplicate listeners)
|
|
1740
|
-
const globalAny = globalThis;
|
|
1741
|
-
const listenersFlag = '__IMESSAGE_CLI_LISTENERS_ATTACHED__';
|
|
1742
|
-
if (!globalAny[listenersFlag]) {
|
|
1743
|
-
globalAny[listenersFlag] = true;
|
|
1744
|
-
process.on('uncaughtException', (err) => {
|
|
1745
|
-
humanError('❌ Fatal Error:', err.message);
|
|
1746
|
-
if (program.opts().verbose) {
|
|
1747
|
-
humanError(err.stack);
|
|
1748
|
-
}
|
|
1749
|
-
logEvent('fatal-error', {
|
|
1750
|
-
command: 'global',
|
|
1751
|
-
phase: 'error',
|
|
1752
|
-
error: {
|
|
1753
|
-
type: err.name,
|
|
1754
|
-
message: err.message,
|
|
1755
|
-
...(err.stack ? { stack: err.stack } : {}),
|
|
1756
|
-
},
|
|
1757
|
-
exitCode: 2,
|
|
1758
|
-
});
|
|
1759
|
-
process.exit(2); // Runtime error
|
|
1760
|
-
});
|
|
1761
|
-
process.on('unhandledRejection', (reason) => {
|
|
1762
|
-
humanError('❌ Unhandled Promise Rejection:', reason);
|
|
1763
|
-
logEvent('unhandled-rejection', {
|
|
1764
|
-
command: 'global',
|
|
1765
|
-
phase: 'error',
|
|
1766
|
-
error: { message: String(reason) },
|
|
1767
|
-
exitCode: 2,
|
|
1768
|
-
});
|
|
1769
|
-
process.exit(2); // Runtime error
|
|
1770
|
-
});
|
|
1771
|
-
}
|
|
1772
|
-
// ============================================================================
|
|
1773
|
-
// Main Execution
|
|
1774
|
-
// ============================================================================
|
|
1775
|
-
async function main() {
|
|
1776
|
-
try {
|
|
1777
|
-
await program.parseAsync(process.argv);
|
|
1778
|
-
}
|
|
1779
|
-
catch (error) {
|
|
1780
|
-
if (error instanceof Error) {
|
|
1781
|
-
humanError(`❌ Error: ${error.message}`);
|
|
1782
|
-
if (program.opts().verbose) {
|
|
1783
|
-
humanError(error.stack);
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
else {
|
|
1787
|
-
humanError('❌ Unknown error:', error);
|
|
1788
|
-
}
|
|
1789
|
-
logEvent('cli-runtime-error', {
|
|
1790
|
-
command: 'global',
|
|
1791
|
-
phase: 'error',
|
|
1792
|
-
error: {
|
|
1793
|
-
type: error instanceof Error ? error.name : 'Unknown',
|
|
1794
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1795
|
-
...(error instanceof Error && error.stack
|
|
1796
|
-
? { stack: error.stack }
|
|
1797
|
-
: {}),
|
|
1798
|
-
},
|
|
1799
|
-
exitCode: 1,
|
|
1800
|
-
});
|
|
1801
|
-
process.exit(1);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
void main();
|
|
1805
|
-
//# sourceMappingURL=cli.js.map
|