mongofire 6.5.5 → 6.5.6

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,1237 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Tell the MongoFire core module not to auto-start from mongofire.config.js.
5
+ // Without this flag, requiring the core module would fire autoStart() in the
6
+ // background, which conflicts with the CLI's own controlled start() calls.
7
+ process.env.MONGOFIRE_CLI = '1';
8
+
9
+ process.on('uncaughtException', (err) => {
10
+ console.error('\n✖ MongoFire error:', err.message || err);
11
+ if (process.env.MONGOFIRE_DEBUG) console.error(err.stack);
12
+ process.exit(1);
13
+ });
14
+ process.on('unhandledRejection', (reason) => {
15
+ console.error('\n✖ MongoFire error:', reason?.message || reason);
16
+ if (process.env.MONGOFIRE_DEBUG) console.error(reason?.stack || reason);
17
+ process.exit(1);
18
+ });
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { pathToFileURL } = require('url');
23
+
24
+ const args = process.argv.slice(2);
25
+ const command = args[0];
26
+ const flags = new Set(args.slice(1));
27
+
28
+ if (flags.has('--esm') && flags.has('--cjs')) {
29
+ console.error('✖ Use only one: --esm OR --cjs');
30
+ process.exit(1);
31
+ }
32
+
33
+ // Load .env early
34
+ (function _loadDotenv() {
35
+ const p = path.join(process.cwd(), '.env');
36
+ if (!fs.existsSync(p)) return;
37
+ try { require('dotenv').config({ path: p }); } catch (_) {}
38
+ })();
39
+
40
+ const IS_TTY = !!process.stdin.isTTY;
41
+
42
+ // ─── Router ───────────────────────────────────────────────────────────────────
43
+ (async () => {
44
+ if (command === 'init') {
45
+ const hasFlags = flags.has('--esm') || flags.has('--cjs') || flags.has('--force') || flags.has('-f');
46
+ if (!hasFlags && IS_TTY) {
47
+ await doInitWizard();
48
+ } else {
49
+ if (!hasFlags) process.stdout.write('ℹ Non-interactive mode — use --cjs, --esm, --force for control\n\n');
50
+ doInitDirect({ force: flags.has('--force') || flags.has('-f'), moduleSystem: flags.has('--esm') ? 'esm' : flags.has('--cjs') ? 'cjs' : null, flagForced: flags.has('--esm') || flags.has('--cjs') });
51
+ }
52
+ } else if (command === 'config') {
53
+ if (IS_TTY) await doConfigWizard();
54
+ else { console.log('ℹ config requires an interactive terminal.'); process.exit(0); }
55
+ } else if (command === 'status') {
56
+ await doStatus();
57
+ } else if (command === 'clean') {
58
+ const hasDaysFlag = [...flags].some((f) => f.startsWith('--days='));
59
+ if (!hasDaysFlag && IS_TTY) await doCleanWizard();
60
+ else await doClean();
61
+ } else if (command === 'conflicts') {
62
+ if (IS_TTY) await doConflictsWizard();
63
+ else { console.log('ℹ conflicts requires an interactive terminal.'); process.exit(0); }
64
+ } else if (command === 'reconcile') {
65
+ const noFullScan = flags.has('--no-full-scan');
66
+ const colArg = [...flags].find((f) => f.startsWith('--collection='));
67
+ const singleCol = colArg ? colArg.split('=')[1] : null;
68
+ await doReconcile({ fullScan: !noFullScan, collection: singleCol });
69
+ } else if (command === 'compact') {
70
+ await doCompact();
71
+ } else if (command === 'reset-local') {
72
+ if (IS_TTY) await doResetLocal();
73
+ else {
74
+ console.log('ℹ reset-local requires an interactive terminal (safety guard).');
75
+ process.exit(0);
76
+ }
77
+ } else {
78
+ showHelp();
79
+ }
80
+ })().catch((err) => {
81
+ console.error('\n✖ MongoFire error:', err.message || err);
82
+ if (process.env.MONGOFIRE_DEBUG) console.error(err.stack);
83
+ process.exit(1);
84
+ });
85
+
86
+ // ─── Help ─────────────────────────────────────────────────────────────────────
87
+ function showHelp() {
88
+ const v = _getVersion();
89
+ console.log(`
90
+ \x1b[33m🔥 MongoFire\x1b[0m \x1b[2mv${v}\x1b[0m
91
+
92
+ \x1b[1mUsage\x1b[0m
93
+
94
+ \x1b[36mnpx mongofire\x1b[0m \x1b[2m<command> [flags]\x1b[0m
95
+
96
+ \x1b[1mCommands\x1b[0m
97
+
98
+ \x1b[36minit\x1b[0m Interactive setup wizard
99
+ \x1b[36mconfig\x1b[0m Update an existing configuration \x1b[2m(TTY only)\x1b[0m
100
+ \x1b[36mstatus\x1b[0m Show pending sync counts
101
+ \x1b[36mclean\x1b[0m Delete old sync records
102
+ \x1b[36mconflicts\x1b[0m View and resolve conflicts \x1b[2m(TTY only)\x1b[0m
103
+ \x1b[36mreconcile\x1b[0m Recover writes lost from crashes
104
+ \x1b[36mcompact\x1b[0m Compact the sync changelog (60\u201380% size reduction)
105
+ \x1b[36mreset-local\x1b[0m Safely wipe local DB + all MongoFire state \x1b[2m(TTY only)\x1b[0m
106
+
107
+ \x1b[1mFlags for \x1b[36minit\x1b[0m
108
+
109
+ \x1b[2m--esm\x1b[0m Force ESM module format
110
+ \x1b[2m--cjs\x1b[0m Force CommonJS module format
111
+ \x1b[2m--force, -f\x1b[0m Overwrite existing config files
112
+
113
+ \x1b[1mFlags for \x1b[36mclean\x1b[0m
114
+
115
+ \x1b[2m--days=N\x1b[0m Delete records older than N days (1–3650, default 7)
116
+
117
+ \x1b[1mFlags for \x1b[36mreconcile\x1b[0m
118
+
119
+ \x1b[2m--no-full-scan\x1b[0m Skip Phase 2 (only check orphaned docmeta)
120
+ \x1b[2m--collection=NAME\x1b[0m Scan a single collection instead of all
121
+
122
+ \x1b[2mTip: set MONGOFIRE_DEBUG=1 for full error stack traces\x1b[0m
123
+ `);
124
+ }
125
+
126
+ function _getVersion() {
127
+ try { return require(path.join(__dirname, '..', 'package.json')).version; } catch { return '?'; }
128
+ }
129
+
130
+ // ─── Load clack ───────────────────────────────────────────────────────────────
131
+ async function loadClack() {
132
+ try {
133
+ return await import('@clack/prompts');
134
+ } catch {
135
+ return buildFallbackClack();
136
+ }
137
+ }
138
+
139
+ // ─── Init Wizard ──────────────────────────────────────────────────────────────
140
+ const DEFAULT_COLLECTIONS = 'orders, products, users';
141
+
142
+ // ─── Modern info block ────────────────────────────────────────────────────────
143
+ function _info(title, lines, tip) {
144
+ const W = 58;
145
+ const pad = (s) => s.length > W ? s.slice(0, W - 1) + '...' : s;
146
+ process.stdout.write('|\n');
147
+ process.stdout.write('| +- ' + title + '\n');
148
+ for (const line of lines) {
149
+ if (line === '') {
150
+ process.stdout.write('| |\n');
151
+ } else if (line.startsWith('✓')) {
152
+ process.stdout.write('| | ' + pad(line) + '\n');
153
+ } else if (line.startsWith('✕')) {
154
+ process.stdout.write('| | ' + pad(line) + '\n');
155
+ } else if (line.startsWith('→')) {
156
+ process.stdout.write('| | ' + pad(line) + '\n');
157
+ } else {
158
+ process.stdout.write('| | ' + pad(line) + '\n');
159
+ }
160
+ }
161
+ if (tip) {
162
+ process.stdout.write('| +- 💡 ' + pad(tip) + '\n');
163
+ } else {
164
+ process.stdout.write('| +-\n');
165
+ }
166
+ }
167
+
168
+ async function doInitWizard() {
169
+ const p = await loadClack();
170
+ const cwd = process.cwd();
171
+
172
+ p.intro(` 🔥 MongoFire  v${_getVersion()} `);
173
+
174
+ // ── Existing config check (Vite-style) ───────────────────────────────────────
175
+ const cfgPath = path.join(cwd, 'mongofire.config.js');
176
+ const entryPath = path.join(cwd, 'mongofire.js');
177
+ const cfgExists = fs.existsSync(cfgPath);
178
+ const entryExists = fs.existsSync(entryPath);
179
+
180
+ if (cfgExists) {
181
+ const existingFiles = [
182
+ cfgExists && 'mongofire.config.js',
183
+ entryExists && 'mongofire.js',
184
+ ].filter(Boolean).join(', ');
185
+
186
+ p.note(
187
+ `${existingFiles} — already exists in this directory`,
188
+ 'Existing setup found'
189
+ );
190
+
191
+ const action = await p.select({
192
+ message: 'What would you like to do?',
193
+ options: [
194
+ { value: 'overwrite', label: 'Overwrite', hint: 'replace with fresh config — start from scratch' },
195
+ { value: 'config', label: 'Update config', hint: 'change collections, interval, or toggle options' },
196
+ { value: 'cancel', label: 'Cancel', hint: 'keep existing files unchanged' },
197
+ ],
198
+ initialValue: 'overwrite',
199
+ });
200
+ if (p.isCancel(action) || action === 'cancel') {
201
+ p.cancel('Cancelled — existing files kept.');
202
+ return;
203
+ }
204
+ if (action === 'config') {
205
+ p.outro('Opening config wizard...');
206
+ return doConfigWizard();
207
+ }
208
+ // action === 'overwrite' — fall through to full wizard below
209
+ }
210
+
211
+ // ── Step 1: Module system ────────────────────────────────────────────────────
212
+ _info('Module System', [
213
+ 'How your project loads and shares code between files.',
214
+ '',
215
+ '→ Auto-detect reads package.json + scans your source files',
216
+ '→ CommonJS you write require() / module.exports',
217
+ '→ ESM you write import / export',
218
+ ], 'Not sure? Pick Auto-detect — correct for most projects.');
219
+ const moduleSystem = await p.select({
220
+ message: 'Module system',
221
+ options: [
222
+ { value: 'auto', label: 'Auto-detect', hint: 'recommended — reads your project automatically' },
223
+ { value: 'cjs', label: 'CommonJS', hint: 'require() / module.exports' },
224
+ { value: 'esm', label: 'ESM', hint: 'import / export' },
225
+ ],
226
+ initialValue: 'auto',
227
+ });
228
+ if (p.isCancel(moduleSystem)) return _cancelled(p);
229
+
230
+ // ── Step 2: Real-time sync ───────────────────────────────────────────────────
231
+ _info('Real-Time Sync', [
232
+ 'Controls how fast changes appear across all connected devices.',
233
+ '',
234
+ '✓ Enabled Atlas Change Streams — changes push within milliseconds',
235
+ '✕ Disabled Polling only — devices check every syncInterval',
236
+ '',
237
+ 'If Change Streams are unavailable, falls back to polling silently.',
238
+ ], 'Recommended ON for most apps.');
239
+ const enableRealtime = await p.confirm({
240
+ message: 'Enable real-time sync?',
241
+ hint: 'ON = instant push OFF = polling only',
242
+ initialValue: true,
243
+ });
244
+ if (p.isCancel(enableRealtime)) return _cancelled(p);
245
+
246
+ // ── Step 3: Multi-tenant ─────────────────────────────────────────────────────
247
+ _info('Multi-Tenant Mode', [
248
+ 'Controls whether each user syncs only their own private data.',
249
+ '',
250
+ '✕ OFF Cafe system, team app, single business',
251
+ ' All users share the same data — simple and fast',
252
+ '',
253
+ '✓ ON SaaS platform, per-user notes, multi-business app',
254
+ ' Each user sees only their own documents',
255
+ ], 'Keep OFF unless users must have isolated private data.');
256
+ const enableMultitenant = await p.confirm({
257
+ message: 'Enable multi-tenant mode?',
258
+ hint: 'OFF = shared data (default) ON = per-user isolation',
259
+ initialValue: false,
260
+ });
261
+ if (p.isCancel(enableMultitenant)) return _cancelled(p);
262
+
263
+ // ── Step 4: TypeScript hints ─────────────────────────────────────────────────
264
+ _info('TypeScript Hints', [
265
+ 'Adds @ts-check + JSDoc types to mongofire.config.js.',
266
+ '',
267
+ '✓ Enabled VS Code shows autocomplete and catches typos',
268
+ ' Works perfectly in plain JavaScript projects',
269
+ ' No TypeScript compiler or tsconfig needed',
270
+ '',
271
+ '✕ Disabled Plain config file, no type annotations',
272
+ ], 'Recommended ON — free autocomplete, zero setup cost.');
273
+ const enableTypescript = await p.confirm({
274
+ message: 'Add TypeScript hints?',
275
+ hint: 'ON = smart autocomplete in VS Code OFF = plain JS',
276
+ initialValue: true,
277
+ });
278
+ if (p.isCancel(enableTypescript)) return _cancelled(p);
279
+
280
+ // ── Step 5: Verbose logging ──────────────────────────────────────────────────
281
+ _info('Verbose Logging', [
282
+ 'Controls how much MongoFire prints to your console.',
283
+ '',
284
+ '✕ OFF Logs only when real changes happen (uploads/downloads)',
285
+ ' Clean output — good for production',
286
+ '',
287
+ '✓ ON Logs every sync cycle even when nothing changed',
288
+ ' Useful when debugging sync issues during development',
289
+ ], 'Keep OFF in production. Enable temporarily to debug.');
290
+ const enableVerbose = await p.confirm({
291
+ message: 'Enable verbose logging?',
292
+ hint: 'OFF = clean logs (recommended) ON = log every cycle',
293
+ initialValue: false,
294
+ });
295
+ if (p.isCancel(enableVerbose)) return _cancelled(p);
296
+
297
+ const featureSet = new Set([
298
+ enableRealtime && 'realtime',
299
+ enableMultitenant && 'multitenant',
300
+ enableTypescript && 'typescript',
301
+ enableVerbose && 'verbose',
302
+ ].filter(Boolean));
303
+
304
+ // ── Step 6: Collections ──────────────────────────────────────────────────────
305
+ _info('Collections to Sync', [
306
+ 'MongoDB collection names MongoFire will keep in sync.',
307
+ '',
308
+ '→ Enter your actual collection names, comma-separated',
309
+ '→ Must match exactly what your Mongoose models use',
310
+ '→ Example: orders, products, users, categories',
311
+ ], 'Press Enter to accept defaults — edit in mongofire.config.js anytime.');
312
+ const rawCols = await p.text({
313
+ message: 'Collections to sync',
314
+ placeholder: DEFAULT_COLLECTIONS,
315
+ defaultValue: DEFAULT_COLLECTIONS,
316
+ validate: () => undefined,
317
+ });
318
+ if (p.isCancel(rawCols)) return _cancelled(p);
319
+ const colsRaw = (rawCols || '').trim() || DEFAULT_COLLECTIONS;
320
+ const collections = colsRaw.split(',').map((s) => s.trim()).filter(Boolean);
321
+
322
+ // ── Step 7: Sync interval ────────────────────────────────────────────────────
323
+ const realtimeOn = featureSet.has('realtime');
324
+ _info('Sync Interval', [
325
+ 'How often MongoFire polls Atlas for new changes.',
326
+ '',
327
+ '→ Lower changes appear faster, more bandwidth used',
328
+ '→ Higher less resource usage, slight delay in updates',
329
+ '',
330
+ realtimeOn
331
+ ? '✓ Realtime is ON — polling is just a safety net. 5s is ideal.'
332
+ : '→ No realtime — 30s is the sweet spot for most apps.',
333
+ ], 'You can change this in mongofire.config.js at any time.');
334
+ const intervalOptions = realtimeOn
335
+ ? [
336
+ { value: 2000, label: '2s', hint: 'aggressive — high bandwidth usage' },
337
+ { value: 5000, label: '5s', hint: 'recommended with Change Streams' },
338
+ { value: 15000, label: '15s', hint: 'light fallback polling' },
339
+ ]
340
+ : [
341
+ { value: 5000, label: '5s', hint: 'fast — great for dev and testing' },
342
+ { value: 15000, label: '15s', hint: 'balanced — low traffic apps' },
343
+ { value: 30000, label: '30s', hint: 'recommended for most apps' },
344
+ { value: 60000, label: '60s', hint: 'conservative — minimal DB load' },
345
+ ];
346
+
347
+ const syncInterval = await p.select({
348
+ message: 'Sync interval',
349
+ options: intervalOptions,
350
+ initialValue: realtimeOn ? 5000 : 30000,
351
+ });
352
+ if (p.isCancel(syncInterval)) return _cancelled(p);
353
+
354
+ // ── Step 8: Owner field (only if multi-tenant) ───────────────────────────────
355
+ let ownerField = '*';
356
+ if (featureSet.has('multitenant')) {
357
+ _info('Owner Field', [
358
+ 'The document field that identifies who owns each record.',
359
+ '',
360
+ '→ Must exist on EVERY document in every synced collection',
361
+ '→ MongoFire only syncs docs where this field = current user',
362
+ '→ You must set this field when creating documents in your app',
363
+ '',
364
+ 'Common names: userId, ownerId, createdBy, accountId',
365
+ ], 'Set req.user._id into this field on every document you create.');
366
+ const ownerRaw = await p.text({
367
+ message: 'Owner field name',
368
+ placeholder: 'userId',
369
+ defaultValue: 'userId',
370
+ hint: 'must exist on every synced document',
371
+ });
372
+ if (p.isCancel(ownerRaw)) return _cancelled(p);
373
+ ownerField = (ownerRaw || '').trim() || 'userId';
374
+ }
375
+
376
+ // ── Step 9: Summary ───────────────────────────────────────────────────────────
377
+ const on = (v) => v ? '● Enabled' : '○ Disabled';
378
+ const summaryLines = [
379
+ `Module system ${moduleSystem === 'auto' ? 'Auto-detect' : moduleSystem.toUpperCase()}`,
380
+ `Real-time sync ${on(enableRealtime)}`,
381
+ `Multi-tenant ${on(enableMultitenant)}`,
382
+ `TypeScript hints ${on(enableTypescript)}`,
383
+ `Verbose logging ${on(enableVerbose)}`,
384
+ `Sync interval ${syncInterval}ms`,
385
+ `Collections ${collections.join(', ')}`,
386
+ ];
387
+ if (featureSet.has('multitenant')) summaryLines.push(`Owner field ${ownerField}`);
388
+ p.note(summaryLines.join('\n'), 'Configuration Summary');
389
+
390
+ const proceed = await p.confirm({
391
+ message: 'Looks good — create these files?',
392
+ initialValue: true,
393
+ });
394
+ if (p.isCancel(proceed) || !proceed) {
395
+ p.cancel('Cancelled — no files changed.');
396
+ return;
397
+ }
398
+
399
+ // ── Write files ──────────────────────────────────────────────────────────────
400
+ const wasAuto = moduleSystem === 'auto';
401
+ const resolvedModule = wasAuto ? detectModuleSystem(cwd, null) : moduleSystem;
402
+ const opts = { collections, realtime: enableRealtime, syncInterval, ownerField, verbose: enableVerbose, typescript: enableTypescript };
403
+
404
+ const envResult = ensureEnvFile(path.join(cwd, '.env'));
405
+ const cfgResult = writeFileIfNeeded(path.join(cwd, 'mongofire.config.js'), buildConfigTemplate(resolvedModule, opts), true);
406
+ const entryResult = writeFileIfNeeded(path.join(cwd, 'mongofire.js'), buildEntryTemplate(resolvedModule, opts), true);
407
+ const hints = collectPackageHints(cwd);
408
+
409
+ // Show clear per-file error if write failed (Windows permission issues etc)
410
+ const failures = [
411
+ cfgResult.action === 'failed' && { file: 'mongofire.config.js', error: cfgResult.error },
412
+ entryResult.action === 'failed' && { file: 'mongofire.js', error: entryResult.error },
413
+ ].filter(Boolean);
414
+
415
+ if (failures.length > 0) {
416
+ const errLines = failures.map((f) =>
417
+ '✕ ' + f.file.padEnd(24) + '' + f.error + ''
418
+ );
419
+ errLines.push('');
420
+ errLines.push('Tip: close the file in VS Code or any editor, then run init again.');
421
+ p.note(errLines.join('\n'), 'Could not write files');
422
+ p.cancel('Setup incomplete — some files could not be written.');
423
+ return;
424
+ }
425
+
426
+ const noteLines = [
427
+ _fileRow('.env', envResult.action),
428
+ _fileRow('mongofire.config.js', cfgResult.action),
429
+ _fileRow('mongofire.js', entryResult.action),
430
+ ];
431
+ if (hints.length) noteLines.push('', ...hints.map((h) => '⚠ ' + h));
432
+ p.note(noteLines.join('\n'), 'Files created');
433
+
434
+ const steps = [
435
+ `Fill in ATLAS_URI inside .env`,
436
+ resolvedModule === 'esm'
437
+ ? `Add import './mongofire.js' to your app entry point`
438
+ : `Add require('./mongofire') to your app entry point`,
439
+ `Replace app.listen() with startApp(app, port):\n`+
440
+ ` ${resolvedModule === 'esm'
441
+ ? "import { startApp } from './mongofire.js'; startApp(app, process.env.PORT || 3000);"
442
+ : "const { startApp } = require('./mongofire'); startApp(app, process.env.PORT || 3000);"}`,
443
+ `Add Schema.plugin(plugin('name')) to each model — import plugin from mongofire.js`,
444
+ `DO NOT call mongoose.connect() — MongoFire owns the local connection`,
445
+ ];
446
+ p.note(steps.map((s, i) => `${i + 1}. ${s}`).join('\n'), 'Next steps');
447
+
448
+ p.outro(`✓ Setup complete! ${resolvedModule.toUpperCase()} · ${wasAuto ? 'auto-detected' : 'manual'}`);
449
+ }
450
+
451
+ // ─── Config Wizard ────────────────────────────────────────────────────────────
452
+ async function doConfigWizard() {
453
+ const p = await loadClack();
454
+ const cwd = process.cwd();
455
+ const cfgPath = path.join(cwd, 'mongofire.config.js');
456
+
457
+ if (!fs.existsSync(cfgPath)) {
458
+ console.log('\n✖ No mongofire.config.js found — run npx mongofire init first.\n');
459
+ process.exit(1);
460
+ }
461
+
462
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m config \x1b[0m`);
463
+
464
+ const section = await p.select({
465
+ message: 'What do you want to change?',
466
+ options: [
467
+ { value: 'collections', label: 'Collections to sync' },
468
+ { value: 'interval', label: 'Sync interval' },
469
+ { value: 'realtime', label: 'Real-time sync toggle' },
470
+ { value: 'multitenant', label: 'Multi-tenant owner field' },
471
+ { value: 'regenerate', label: 'Regenerate all files', hint: 'runs full wizard' },
472
+ ],
473
+ });
474
+ if (p.isCancel(section)) return _cancelled(p);
475
+ if (section === 'regenerate') { p.outro(''); return doInitWizard(); }
476
+
477
+ let content = '';
478
+ try { content = fs.readFileSync(cfgPath, 'utf8'); } catch (_) {}
479
+
480
+ if (section === 'collections') {
481
+ const match = content.match(/collections:\s*\[([\s\S]*?)\]/);
482
+ const current = match ? match[1].replace(/['"\s]/g, '').split(',').filter(Boolean).join(', ') : 'users, products';
483
+ const rawCols = await p.text({ message: 'Collections to sync', defaultValue: current, placeholder: current });
484
+ if (p.isCancel(rawCols)) return _cancelled(p);
485
+ const cols = rawCols.split(',').map((s) => s.trim()).filter(Boolean);
486
+ const newList = cols.map((c) => ` '${c}',`).join('\n');
487
+ fs.writeFileSync(cfgPath, content.replace(/collections:\s*\[([\s\S]*?)\]/, `collections: [\n${newList}\n ]`), 'utf8');
488
+ p.outro(`\x1b[32mUpdated\x1b[0m collections → ${cols.join(', ')}`);
489
+
490
+ } else if (section === 'interval') {
491
+ const syncInterval = await p.select({
492
+ message: 'New sync interval',
493
+ options: [
494
+ { value: 5000, label: '5s', hint: 'dev' },
495
+ { value: 15000, label: '15s', hint: 'balanced' },
496
+ { value: 30000, label: '30s', hint: 'recommended' },
497
+ { value: 60000, label: '60s', hint: 'conservative' },
498
+ ],
499
+ initialValue: 30000,
500
+ });
501
+ if (p.isCancel(syncInterval)) return _cancelled(p);
502
+ const updated = content.replace(/syncInterval:\s*\d+/, `syncInterval: ${syncInterval}`);
503
+ if (updated === content) {
504
+ p.cancel('✖ Could not find syncInterval in config — edit manually.');
505
+ return;
506
+ }
507
+ fs.writeFileSync(cfgPath, updated, 'utf8');
508
+ p.outro(`\x1b[32mUpdated\x1b[0m syncInterval → ${syncInterval}ms`);
509
+
510
+ } else if (section === 'realtime') {
511
+ const isOn = /realtime:\s*true/.test(content);
512
+ const realtime = await p.confirm({ message: 'Enable real-time sync?', initialValue: !isOn });
513
+ if (p.isCancel(realtime)) return _cancelled(p);
514
+ const updated = content.replace(/realtime:\s*(true|false)/, `realtime: ${realtime}`);
515
+ if (updated === content) {
516
+ p.cancel('✖ Could not find realtime field in config — edit manually.');
517
+ return;
518
+ }
519
+ fs.writeFileSync(cfgPath, updated, 'utf8');
520
+ p.outro(`\x1b[32mUpdated\x1b[0m realtime → ${realtime}`);
521
+
522
+ } else if (section === 'multitenant') {
523
+ const owner = await p.text({ message: "Owner field path (use '*' to disable)", defaultValue: 'userId', placeholder: 'userId' });
524
+ if (p.isCancel(owner)) return _cancelled(p);
525
+ const updated = content.replace(/syncOwner:\s*['"][^'"]*['"]/, `syncOwner: '${owner}'`);
526
+ if (updated === content) {
527
+ p.cancel('✖ Could not find syncOwner in config — edit manually.');
528
+ return;
529
+ }
530
+ fs.writeFileSync(cfgPath, updated, 'utf8');
531
+ p.outro(`\x1b[32mUpdated\x1b[0m syncOwner → ${owner}`);
532
+ }
533
+ }
534
+
535
+ // ─── Init direct (flag mode) ──────────────────────────────────────────────────
536
+ function doInitDirect(options) {
537
+ const cwd = process.cwd();
538
+ const ms = detectModuleSystem(cwd, options.moduleSystem);
539
+ const source = options.flagForced ? `--${ms}` : 'auto-detected';
540
+ const opts = { collections: ['users', 'products'], realtime: false, syncInterval: 30000, ownerField: '*' };
541
+
542
+ const envResult = ensureEnvFile(path.join(cwd, '.env'));
543
+ const cfgResult = writeFileIfNeeded(path.join(cwd, 'mongofire.config.js'), buildConfigTemplate(ms, opts), !!options.force);
544
+ const entryResult = writeFileIfNeeded(path.join(cwd, 'mongofire.js'), buildEntryTemplate(ms, {}), !!options.force);
545
+ const hints = collectPackageHints(cwd);
546
+
547
+ console.log(`\x1b[33m 🔥 MongoFire\x1b[0m \x1b[2mv${_getVersion()}\x1b[0m\n`);
548
+ console.log(_fileRow('.env', envResult.action));
549
+ console.log(_fileRow('mongofire.config.js', cfgResult.action));
550
+ console.log(_fileRow('mongofire.js', entryResult.action));
551
+ if (!options.force && (cfgResult.action === 'skipped' || entryResult.action === 'skipped'))
552
+ console.log('\n\x1b[2m Existing files kept — use --force to regenerate.\x1b[0m');
553
+ if (hints.length) { console.log(''); hints.forEach((h) => console.log(`\x1b[33m ⚠\x1b[0m ${h}`)); }
554
+ console.log(`\n\x1b[32m Done!\x1b[0m \x1b[2m${ms.toUpperCase()} · ${source}\x1b[0m\n`);
555
+ }
556
+
557
+ // ─── Status ───────────────────────────────────────────────────────────────────
558
+ async function doStatus() {
559
+ const p = await loadClack();
560
+ const cwd = process.cwd();
561
+ const cfgPath = resolveConfigPath(cwd);
562
+ if (!cfgPath) return _noConfig(p);
563
+
564
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m status \x1b[0m`);
565
+
566
+ const spin = p.spinner();
567
+ spin.start('Connecting…');
568
+ const config = await loadConfig(cfgPath, cwd);
569
+ const mongofire = requireMongofire();
570
+ await mongofire.start(config);
571
+ const s = await mongofire.status();
572
+ spin.stop('Connected');
573
+
574
+ const onlineLabel = s.online ? '\x1b[32m● online\x1b[0m' : '\x1b[31m● offline\x1b[0m';
575
+ p.note(
576
+ [
577
+ `Atlas ${onlineLabel}`,
578
+ ``,
579
+ `Pending \x1b[1m${s.pending}\x1b[0m \x1b[2munsynced operations\x1b[0m`,
580
+ ` Creates ${s.creates}`,
581
+ ` Updates ${s.updates}`,
582
+ ` Deletes ${s.deletes}`,
583
+ ].join('\n'),
584
+ 'Sync state'
585
+ );
586
+
587
+ p.outro(s.pending === 0 ? '\x1b[32mAll caught up\x1b[0m' : `\x1b[33m${s.pending} operation(s) pending\x1b[0m`);
588
+
589
+ await mongofire.stop();
590
+ process.exit(0);
591
+ }
592
+
593
+ // ─── Clean ────────────────────────────────────────────────────────────────────
594
+ async function doCleanWizard() {
595
+ const p = await loadClack();
596
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m clean \x1b[0m`);
597
+
598
+ const days = await p.select({
599
+ message: 'Delete records synced longer than',
600
+ options: [
601
+ { value: 1, label: '1 day', hint: 'aggressive' },
602
+ { value: 3, label: '3 days' },
603
+ { value: 7, label: '7 days', hint: 'recommended' },
604
+ { value: 14, label: '14 days' },
605
+ { value: 30, label: '30 days', hint: 'conservative' },
606
+ ],
607
+ initialValue: 7,
608
+ });
609
+ if (p.isCancel(days)) return _cancelled(p);
610
+
611
+ const ok = await p.confirm({ message: `Delete records older than ${days} day(s) — this cannot be undone`, initialValue: false });
612
+ if (p.isCancel(ok) || !ok) { p.cancel('Cancelled — nothing deleted.'); return; }
613
+
614
+ // Pass days directly — no outer-scope flags mutation needed.
615
+ await _runClean(p, days);
616
+ }
617
+
618
+ async function doClean() {
619
+ const p = await loadClack();
620
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m clean \x1b[0m`);
621
+ const daysArg = [...flags].find((f) => f.startsWith('--days='));
622
+ const days = daysArg ? parseInt(daysArg.split('=')[1], 10) : 7;
623
+ await _runClean(p, days);
624
+ }
625
+
626
+ async function _runClean(p, days) {
627
+ // Validate: must be integer, 1–3650 (10 years max to guard against typos).
628
+ if (!Number.isInteger(days) || days < 1 || days > 3650) {
629
+ console.error('✖ Invalid --days value (must be 1–3650)');
630
+ process.exit(1);
631
+ }
632
+
633
+ const cwd = process.cwd();
634
+ const cfgPath = resolveConfigPath(cwd);
635
+ if (!cfgPath) return _noConfig(p);
636
+
637
+ const spin = p.spinner();
638
+ spin.start(`Deleting records older than ${days} day(s)…`);
639
+ const config = await loadConfig(cfgPath, cwd);
640
+ const mongofire = requireMongofire();
641
+ await mongofire.start(config);
642
+ const count = await mongofire.clean(days);
643
+ spin.stop(`Deleted ${count} record(s)`);
644
+
645
+ p.outro(count > 0 ? `\x1b[32m${count} record(s) removed\x1b[0m` : '\x1b[2mNothing to clean\x1b[0m');
646
+ await mongofire.stop();
647
+ process.exit(0);
648
+ }
649
+
650
+ // ─── Conflicts ────────────────────────────────────────────────────────────────
651
+ async function doConflictsWizard() {
652
+ const p = await loadClack();
653
+ const cwd = process.cwd();
654
+ const cfgPath = resolveConfigPath(cwd);
655
+ if (!cfgPath) return _noConfig(p);
656
+
657
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m conflicts \x1b[0m`);
658
+
659
+ const spin = p.spinner();
660
+ spin.start('Loading conflicts…');
661
+ const config = await loadConfig(cfgPath, cwd);
662
+ const mongofire = requireMongofire();
663
+ await mongofire.start(config);
664
+ const list = await mongofire.conflicts();
665
+ spin.stop(`${list.length} conflict(s) found`);
666
+
667
+ if (!list.length) {
668
+ p.outro('\x1b[32mNo unresolved conflicts\x1b[0m');
669
+ await mongofire.stop();
670
+ process.exit(0);
671
+ }
672
+
673
+ const rows = list.map((c) =>
674
+ [`\x1b[1m${c.collection}/${c.docId}\x1b[0m \x1b[2mop:${c.type} v${c.version}\x1b[0m`, c.lastError ? ` \x1b[31m${c.lastError}\x1b[0m` : ''].filter(Boolean).join('\n')
675
+ ).join('\n\n');
676
+ p.note(rows, `${list.length} unresolved conflict(s)`);
677
+
678
+ const action = await p.select({
679
+ message: 'How do you want to resolve?',
680
+ options: [
681
+ { value: 'retry_all', label: 'Retry all', hint: 'reset to pending, sync re-attempts' },
682
+ { value: 'dismiss_all', label: 'Dismiss all', hint: 'discard — keep local state' },
683
+ { value: 'pick_retry', label: 'Pick one to retry' },
684
+ { value: 'pick_dismiss', label: 'Pick one to dismiss' },
685
+ { value: 'nothing', label: 'Do nothing' },
686
+ ],
687
+ });
688
+ if (p.isCancel(action)) return _cancelled(p);
689
+
690
+ if (action === 'nothing') {
691
+ p.outro('\x1b[2mNo changes made\x1b[0m');
692
+ } else if (action === 'retry_all') {
693
+ const s2 = p.spinner(); s2.start('Retrying…');
694
+ for (const c of list) await mongofire.retryConflict(c.opId);
695
+ s2.stop(`Retried ${list.length} conflict(s)`);
696
+ p.outro('\x1b[32mAll conflicts reset to pending\x1b[0m');
697
+ } else if (action === 'dismiss_all') {
698
+ const s2 = p.spinner(); s2.start('Dismissing…');
699
+ for (const c of list) await mongofire.dismissConflict(c.opId);
700
+ s2.stop(`Dismissed ${list.length} conflict(s)`);
701
+ p.outro('\x1b[32mAll conflicts dismissed\x1b[0m');
702
+ } else {
703
+ const opId = await p.select({
704
+ message: 'Select conflict',
705
+ options: list.map((c) => ({ value: c.opId, label: `${c.collection}/${c.docId}`, hint: `${c.type} v${c.version}` })),
706
+ });
707
+ if (p.isCancel(opId)) return _cancelled(p);
708
+ if (action === 'pick_retry') { await mongofire.retryConflict(opId); p.outro('\x1b[32mConflict reset to pending\x1b[0m'); }
709
+ else { await mongofire.dismissConflict(opId); p.outro('\x1b[32mConflict dismissed\x1b[0m'); }
710
+ }
711
+
712
+ await mongofire.stop();
713
+ process.exit(0);
714
+ }
715
+
716
+ // ─── Compact ──────────────────────────────────────────────────────────────────
717
+ async function doCompact() {
718
+ const p = await loadClack();
719
+ const cwd = process.cwd();
720
+ const cfgPath = resolveConfigPath(cwd);
721
+ if (!cfgPath) return _noConfig(p);
722
+
723
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m compact \x1b[0m`);
724
+
725
+ const daysArg = [...flags].find((f) => f.startsWith('--days='));
726
+ const days = daysArg ? parseInt(daysArg.split('=')[1], 10) : 7;
727
+ const colArg = [...flags].find((f) => f.startsWith('--collection='));
728
+ const col = colArg ? colArg.split('=')[1] : null;
729
+
730
+ if (!Number.isInteger(days) || days < 1 || days > 3650) {
731
+ console.error('✖ Invalid --days value (must be 1–3650)');
732
+ process.exit(1);
733
+ }
734
+
735
+ const spin = p.spinner();
736
+ spin.start(`Compacting changelog (retention: ${days} day(s)${col ? `, collection: ${col}` : ''})…`);
737
+
738
+ const config = await loadConfig(cfgPath, cwd);
739
+ const mongofire = requireMongofire();
740
+ await mongofire.start(config);
741
+ const result = await mongofire.compact({ retentionDays: days, collection: col || undefined, verbose: false });
742
+ spin.stop(`Done — scanned ${result.scanned}, deleted ${result.deleted}, kept ${result.kept} (${result.durationMs}ms)`);
743
+
744
+ p.outro(
745
+ result.deleted > 0
746
+ ? `\x1b[32m${result.deleted} superseded row(s) removed\x1b[0m`
747
+ : '\x1b[2mNothing to compact — changelog is already lean\x1b[0m'
748
+ );
749
+ await mongofire.stop();
750
+ process.exit(0);
751
+ }
752
+
753
+ // ─── Reconcile ────────────────────────────────────────────────────────────────
754
+ async function doReconcile(opts = {}) {
755
+ const p = await loadClack();
756
+ const cwd = process.cwd();
757
+ const cfgPath = resolveConfigPath(cwd);
758
+ if (!cfgPath) return _noConfig(p);
759
+
760
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m reconcile \x1b[0m`);
761
+
762
+ const fullScan = opts.fullScan !== false;
763
+ const scanLabel = fullScan ? 'full scan' : 'Phase 1 only (--no-full-scan)';
764
+
765
+ const spin = p.spinner();
766
+ spin.start(`Scanning for lost writes… \x1b[2m(${scanLabel})\x1b[0m`);
767
+ const config = await loadConfig(cfgPath, cwd);
768
+ const mongofire = requireMongofire();
769
+ await mongofire.start(config);
770
+
771
+ const reconcileArg = opts.collection
772
+ ? opts.collection
773
+ : { fullScan, verbose: true };
774
+
775
+ const results = await mongofire.reconcile(reconcileArg, { fullScan, verbose: true });
776
+ const total = results.reduce((s, r) => s + (r.totalQueued || 0), 0);
777
+ spin.stop('Scan complete');
778
+
779
+ const rows = results.map((r) => {
780
+ if (r.error) return `\x1b[31m✖\x1b[0m ${r.collection} \x1b[2m${r.error}\x1b[0m`;
781
+ const q = (r.phase1?.queued || 0) + (r.phase2?.queued || 0);
782
+ return `${q > 0 ? '\x1b[33m↻\x1b[0m' : '\x1b[32m✓\x1b[0m'} \x1b[1m${r.collection}\x1b[0m \x1b[2mP1:${r.phase1?.queued || 0} P2:${r.phase2?.queued || 0} re-queued\x1b[0m`;
783
+ }).join('\n');
784
+ p.note(rows, 'Results');
785
+
786
+ p.outro(total > 0 ? `\x1b[33m${total} lost write(s) recovered\x1b[0m` : '\x1b[32mNo untracked operations found\x1b[0m');
787
+ await mongofire.stop();
788
+ process.exit(0);
789
+ }
790
+
791
+ // ─── Config templates ─────────────────────────────────────────────────────────
792
+ function buildConfigTemplate(ms, opts = {}) {
793
+ const cols = (opts.collections?.length ? opts.collections : ['orders', 'products', 'users']).map((c) => ` '${c}',`).join('\n');
794
+ const realtime = !!opts.realtime;
795
+ const syncInterval = opts.syncInterval || 30000;
796
+ const ownerField = (opts.ownerField && opts.ownerField !== '*') ? opts.ownerField : '*';
797
+ const tsCheck = opts.typescript ? '\n// @ts-check\n/** @type {import("mongofire").SyncConfig} */' : '';
798
+ const verbose = !!opts.verbose;
799
+ const syncLog = verbose
800
+ ? " console.log(`[MongoFire] ↑${result.uploaded} ↓${result.downloaded} DEL:${result.deleted}`);"
801
+ : " if (result.deleted + result.downloaded + result.uploaded > 0) {\n console.log(`[MongoFire] Synced: ↑${result.uploaded} ↓${result.downloaded} DEL:${result.deleted}`);\n }";
802
+
803
+ const body = [
804
+ ` // ── Required ─────────────────────────────────────────────────────────`,
805
+ ` localUri: process.env.LOCAL_URI || 'mongodb://127.0.0.1:27017',`,
806
+ ` atlasUri: process.env.ATLAS_URI,`,
807
+ ` dbName: process.env.DB_NAME || 'myapp',`,
808
+ ``,
809
+ ` collections: [`,
810
+ cols,
811
+ ` ],`,
812
+ ``,
813
+ ` // ── Sync behaviour ───────────────────────────────────────────────────`,
814
+ ` syncInterval: ${syncInterval}, // ms — minimum 500`,
815
+ ` batchSize: 200, // docs per upload batch (1–10000)`,
816
+ ` realtime: ${realtime}, // Atlas Change Streams — instant push`,
817
+ ` syncOwner: '${ownerField}', // '*' = all data · 'userId' = per-user isolation`,
818
+ ``,
819
+ ` // ── Maintenance (safe defaults — change when needed) ─────────────────`,
820
+ ` cleanDays: 7, // auto-delete synced records older than N days`,
821
+ ` reconcileOnStart: true, // repair writes lost to crashes on startup`,
822
+ ` verbose: ${String(verbose).padEnd(5)}, // log every sync cycle (useful for debugging)`,
823
+ ``,
824
+ ` // ── Advanced (disabled by default — uncomment to enable) ─────────────`,
825
+ ` // autoCompact: true, // compact changelog (60–80% size reduction)`,
826
+ ` // compactEvery: 100, // run compaction every N sync cycles`,
827
+ ` // compactRetentionDays: 7, // keep rows newer than this after compaction`,
828
+ ` // rateLimiter: { // protect Atlas from sync storms`,
829
+ ` // tokenBucket: { capacity: 100, refillRate: 50 },`,
830
+ ` // circuitBreaker: { failureThreshold: 5, timeout: 30000 },`,
831
+ ` // },`,
832
+ ownerField === '*'
833
+ ? ` // syncOwner: 'userId', // uncomment for multi-tenant (per-user sync)`
834
+ : ` // multi-tenant ON: only syncs docs where document.${ownerField} === syncOwner value`,
835
+ ``,
836
+ ` // ── Callbacks ────────────────────────────────────────────────────────`,
837
+ ` onSync(result) {`,
838
+ syncLog,
839
+ ` },`,
840
+ ` onError(err) {`,
841
+ ` console.error('[MongoFire] Sync error:', err.message);`,
842
+ ` },`,
843
+ ].join('\n');
844
+
845
+ if (ms === 'esm') {
846
+ return `${tsCheck}\nimport 'dotenv/config';\n\n// MongoFire configuration (ESM)\nexport default {\n${body}\n};\n`;
847
+ }
848
+ return `${tsCheck}\n'use strict';\ntry { require('dotenv').config(); } catch (_) {}\n\n// MongoFire configuration (CommonJS)\nmodule.exports = {\n${body}\n};\n`;
849
+ }
850
+
851
+ function buildEntryTemplate(ms, opts = {}) {
852
+ const syncLog = opts.verbose
853
+ ? `_mongofire.on('sync', (r) => console.log(\`🔄 [MongoFire] Synced: ↑\${r.uploaded} ↓\${r.downloaded} DEL:\${r.deleted}\`));`
854
+ : `_mongofire.on('sync', (r) => {\n if (r.uploaded + r.downloaded + r.deleted > 0) {\n console.log(\`🔄 [MongoFire] Sync complete — ↑\${r.uploaded} uploaded ↓\${r.downloaded} downloaded 🗑 \${r.deleted} deleted\`);\n }\n});`;
855
+
856
+ if (ms === 'esm') {
857
+ return [
858
+ `// mongofire.js — ESM production entry point`,
859
+ `//`,
860
+ `// ⚠️ DO NOT call mongoose.connect() — MongoFire handles this automatically.`,
861
+ `//`,
862
+ `// server.js usage:`,
863
+ `// import { startApp } from './mongofire.js';`,
864
+ `// startApp(app, process.env.PORT || 3000);`,
865
+ `//`,
866
+ `// model.js usage:`,
867
+ `// import { plugin } from './mongofire.js';`,
868
+ `// UserSchema.plugin(plugin('users'));`,
869
+ ``,
870
+ `import { createRequire } from 'module';`,
871
+ `import { fileURLToPath, pathToFileURL } from 'url';`,
872
+ `import path from 'path';`,
873
+ `import fs from 'fs';`,
874
+ ``,
875
+ `const require = createRequire(import.meta.url);`,
876
+ `const __dirname = path.dirname(fileURLToPath(import.meta.url));`,
877
+ ``,
878
+ `// Load .env synchronously — dotenv is CJS, always safe to require()`,
879
+ `try {`,
880
+ ` const e = [path.join(process.cwd(),'.env'), path.join(__dirname,'.env')].find(p => fs.existsSync(p));`,
881
+ ` if (e) require('dotenv').config({ path: e });`,
882
+ `} catch (_) {}`,
883
+ ``,
884
+ `const _mongofire = require('mongofire');`,
885
+ ``,
886
+ `_mongofire.on('localReady', () => console.log('✅ [MongoFire] Local MongoDB connected'));`,
887
+ `_mongofire.on('online', () => console.log('🌐 [MongoFire] Atlas connected — sync active'));`,
888
+ `_mongofire.on('offline', () => console.log('📴 [MongoFire] Atlas offline — changes queued locally'));`,
889
+ syncLog,
890
+ `_mongofire.on('error', (err) => console.error('❌ [MongoFire] Error:', err?.message || err));`,
891
+ ``,
892
+ `// loadConfigFile() auto-detects CJS/ESM — safe on Node 22+ (no ERR_INTERNAL_ASSERTION)`,
893
+ `async function _loadAndStart() {`,
894
+ ` const result = await _mongofire.loadConfigFile([process.cwd(), __dirname]);`,
895
+ ` if (!result) { console.error('\\n❌ mongofire.config.js not found. Run: npx mongofire init\\n'); process.exit(1); }`,
896
+ ` if (!result.config?.collections?.length) { console.error('\\n❌ mongofire.config.js has no collections.\\n'); process.exit(1); }`,
897
+ ` return _mongofire.start(result.config);`,
898
+ `}`,
899
+ `const _startPromise = _loadAndStart();`,
900
+ ``,
901
+ `export async function startApp(app, port = 3000) {`,
902
+ ` await _startPromise.catch(() => {});`,
903
+ ` try {`,
904
+ ` await _mongofire.localReady;`,
905
+ ` } catch (err) {`,
906
+ ` console.error('\\n❌ [MongoFire] Local MongoDB failed to connect.\\n' +`,
907
+ ` \` Reason: \${err?.message || err}\\n\` +`,
908
+ ` ' Server will NOT start in a broken state.\\n');`,
909
+ ` process.exit(1);`,
910
+ ` }`,
911
+ ` return new Promise((resolve, reject) => {`,
912
+ ` const server = app.listen(Number(port) || 3000, (err) => {`,
913
+ ` if (err) { console.error('❌ Server listen failed:', err.message); reject(err); process.exit(1); }`,
914
+ ` console.log(\`🚀 [MongoFire] Server ready on port \${port}\`);`,
915
+ ` resolve(server);`,
916
+ ` });`,
917
+ ` server.on('error', (err) => { console.error('❌ Server error:', err.message); reject(err); process.exit(1); });`,
918
+ ` });`,
919
+ `}`,
920
+ ``,
921
+ `export function plugin(collectionName, options = {}) {`,
922
+ ` return _mongofire.plugin(collectionName, options);`,
923
+ `}`,
924
+ ``,
925
+ `export const localReady = _mongofire.localReady;`,
926
+ `export const ready = _startPromise;`,
927
+ `export { _mongofire as mongofire };`,
928
+ `export default _mongofire;`,
929
+ ``,
930
+ ].join('\n');
931
+ }
932
+
933
+ return [
934
+ `// mongofire.js — CJS production entry point`,
935
+ `//`,
936
+ `// ⚠️ DO NOT call mongoose.connect() — MongoFire handles this automatically.`,
937
+ `//`,
938
+ `// server.js usage:`,
939
+ `// const { startApp } = require('./mongofire');`,
940
+ `// startApp(app, process.env.PORT || 3000);`,
941
+ `//`,
942
+ `// model.js usage:`,
943
+ `// const { plugin } = require('./mongofire');`,
944
+ `// UserSchema.plugin(plugin('users'));`,
945
+ ``,
946
+ `'use strict';`,
947
+ `const _mongofire = require('mongofire');`,
948
+ `const _config = require('./mongofire.config');`,
949
+ ``,
950
+ `_mongofire.on('localReady', () => console.log('✅ [MongoFire] Local MongoDB connected'));`,
951
+ `_mongofire.on('online', () => console.log('🌐 [MongoFire] Atlas connected — sync active'));`,
952
+ `_mongofire.on('offline', () => console.log('📴 [MongoFire] Atlas offline — changes queued locally'));`,
953
+ syncLog,
954
+ `_mongofire.on('error', (err) => console.error('❌ [MongoFire] Error:', err?.message || err));`,
955
+ ``,
956
+ `const _startPromise = _mongofire.start(_config);`,
957
+ ``,
958
+ `async function startApp(app, port = 3000) {`,
959
+ ` try {`,
960
+ ` await _mongofire.localReady;`,
961
+ ` } catch (err) {`,
962
+ ` console.error('\\n❌ [MongoFire] Local MongoDB failed to connect.\\n' +`,
963
+ ` \` Reason: \${err?.message || err}\\n\` +`,
964
+ ` ' Server will NOT start in a broken state.\\n');`,
965
+ ` process.exit(1);`,
966
+ ` }`,
967
+ ` return new Promise((resolve, reject) => {`,
968
+ ` const server = app.listen(Number(port) || 3000, (err) => {`,
969
+ ` if (err) { console.error('❌ Server listen failed:', err.message); reject(err); process.exit(1); }`,
970
+ ` console.log(\`🚀 [MongoFire] Server ready on port \${port}\`);`,
971
+ ` resolve(server);`,
972
+ ` });`,
973
+ ` server.on('error', (err) => { console.error('❌ Server error:', err.message); reject(err); process.exit(1); });`,
974
+ ` });`,
975
+ `}`,
976
+ ``,
977
+ `function plugin(collectionName, options = {}) {`,
978
+ ` return _mongofire.plugin(collectionName, options);`,
979
+ `}`,
980
+ ``,
981
+ `module.exports = { startApp, plugin, mongofire: _mongofire, ready: _startPromise, localReady: _mongofire.localReady };`,
982
+ ``,
983
+ ].join('\n');
984
+ }
985
+
986
+ // ─── File helpers ─────────────────────────────────────────────────────────────
987
+ function ensureEnvFile(envPath) {
988
+ const defaults = [
989
+ ['ATLAS_URI', 'mongodb+srv://USERNAME:PASSWORD@cluster0.xxxxx.mongodb.net/'],
990
+ ['LOCAL_URI', 'mongodb://127.0.0.1:27017'],
991
+ ['DB_NAME', 'myapp'],
992
+ ];
993
+ if (!fs.existsSync(envPath)) {
994
+ fs.writeFileSync(envPath, '# MongoFire\n' + defaults.map(([k, v]) => `${k}=${v}`).join('\n') + '\n', 'utf8');
995
+ return { action: 'created' };
996
+ }
997
+ const env = fs.readFileSync(envPath, 'utf8');
998
+ const missing = defaults.filter(([k]) => !new RegExp(`^\\s*${escapeRE(k)}\\s*=`, 'm').test(env));
999
+ if (!missing.length) return { action: 'unchanged' };
1000
+ fs.appendFileSync(envPath, '\n# MongoFire\n' + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n', 'utf8');
1001
+ return { action: 'updated' };
1002
+ }
1003
+
1004
+ function writeFileIfNeeded(filePath, content, force) {
1005
+ const exists = fs.existsSync(filePath);
1006
+ if (exists && !force) return { action: 'skipped' };
1007
+
1008
+ // Write to a temp file first, then rename — avoids Windows permission
1009
+ // errors when overwriting a file that may be open in another process.
1010
+ const tmpPath = filePath + '.mftmp';
1011
+ try {
1012
+ fs.writeFileSync(tmpPath, content, 'utf8');
1013
+ // On Windows, renameSync fails if dest exists — remove it first
1014
+ if (exists) {
1015
+ try { fs.unlinkSync(filePath); } catch (_) {}
1016
+ }
1017
+ fs.renameSync(tmpPath, filePath);
1018
+ } catch (err) {
1019
+ // Fallback: direct write (works on most systems)
1020
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
1021
+ fs.writeFileSync(filePath, content, { encoding: 'utf8', flag: 'w' });
1022
+ }
1023
+ return { action: exists ? 'overwritten' : 'created' };
1024
+ }
1025
+
1026
+ function collectPackageHints(cwd) {
1027
+ const pkg = readPackageJson(cwd);
1028
+ const all = Object.assign({}, pkg?.dependencies, pkg?.devDependencies, pkg?.peerDependencies);
1029
+ const out = [];
1030
+ if (!all.mongoose) out.push('mongoose not installed — npm i mongoose');
1031
+ if (!all.dotenv) out.push('dotenv not installed — npm i dotenv');
1032
+ return out;
1033
+ }
1034
+
1035
+ function _fileRow(name, action) {
1036
+ const icons = { created: '\x1b[32m+\x1b[0m', overwritten: '\x1b[33m~\x1b[0m', skipped: '\x1b[2m–\x1b[0m', unchanged: '\x1b[2m–\x1b[0m', updated: '\x1b[32m+\x1b[0m' };
1037
+ const labels = { created: '\x1b[2mcreated\x1b[0m', overwritten: '\x1b[33mupdated\x1b[0m', skipped: '\x1b[2mexists\x1b[0m', unchanged: '\x1b[2munchanged\x1b[0m', updated: '\x1b[2mupdated\x1b[0m' };
1038
+ return ` ${icons[action] || ' '} ${name.padEnd(24)}${labels[action] || action}`;
1039
+ }
1040
+
1041
+ // ─── Reset Local ──────────────────────────────────────────────────────────────
1042
+ // Safe interactive wipe of the local MongoDB database.
1043
+ // Drops all _mf_* internal collections plus the configured data collections so
1044
+ // that the next startup triggers a clean bootstrap from Atlas, with no stale ops.
1045
+ async function doResetLocal() {
1046
+ const p = await loadClack();
1047
+ const cwd = process.cwd();
1048
+ const cfgPath = resolveConfigPath(cwd);
1049
+ if (!cfgPath) return _noConfig(p);
1050
+
1051
+ p.intro(`\x1b[33m 🔥 MongoFire \x1b[0m\x1b[2m reset-local \x1b[0m`);
1052
+
1053
+ p.note(
1054
+ [
1055
+ '\x1b[1mThis will permanently delete:\x1b[0m',
1056
+ ' • All MongoFire internal collections (_mf_changetrack, _mf_docmeta,',
1057
+ ' _mf_sync_state, _mf_ops)',
1058
+ ' • All configured data collections listed in mongofire.config.js',
1059
+ '',
1060
+ 'The next startup will re-bootstrap from Atlas.',
1061
+ '\x1b[31mAny unsynced local changes will be lost.\x1b[0m',
1062
+ ].join('\n'),
1063
+ 'WARNING'
1064
+ );
1065
+
1066
+ const ok = await p.confirm({
1067
+ message: 'Are you sure you want to reset the entire local database?',
1068
+ initialValue: false,
1069
+ });
1070
+ if (p.isCancel(ok) || !ok) { p.cancel('Cancelled — nothing was deleted.'); return; }
1071
+
1072
+ const spin = p.spinner();
1073
+ spin.start('Connecting to local MongoDB…');
1074
+
1075
+ let config;
1076
+ try {
1077
+ config = await loadConfig(cfgPath, cwd);
1078
+ } catch (err) {
1079
+ spin.stop('Failed to load config');
1080
+ p.cancel(`Config error: ${err.message}`);
1081
+ process.exit(1);
1082
+ }
1083
+
1084
+ const mongofire = requireMongofire();
1085
+ await mongofire.start(config);
1086
+
1087
+ const localDb = mongofire._conn?.local || mongofire.conn?.local;
1088
+ const userCols = Array.isArray(config.collections) ? config.collections : [];
1089
+ const mfCols = ['_mf_changetrack', '_mf_docmeta', '_mf_sync_state', '_mf_ops', '_mf_devices'];
1090
+ const allToDrop = [...new Set([...mfCols, ...userCols])];
1091
+
1092
+ spin.start(`Dropping ${allToDrop.length} collection(s)…`);
1093
+
1094
+ let dropped = 0, failed = 0;
1095
+ for (const col of allToDrop) {
1096
+ try {
1097
+ await localDb.collection(col).drop();
1098
+ dropped++;
1099
+ } catch (err) {
1100
+ // collection may not exist — that is fine
1101
+ if (!err.message?.includes('ns not found') && !err.codeName?.includes('NamespaceNotFound')) {
1102
+ failed++;
1103
+ console.error(`\n✖ Failed to drop "${col}":`, err.message);
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ spin.stop(`Dropped ${dropped} collection(s)${failed ? `, ${failed} error(s)` : ''}`);
1109
+
1110
+ p.outro(
1111
+ failed === 0
1112
+ ? '\x1b[32mLocal database reset complete.\x1b[0m Start your app to re-bootstrap from Atlas.'
1113
+ : `\x1b[33mReset finished with ${failed} error(s).\x1b[0m Check logs above.`
1114
+ );
1115
+
1116
+ await mongofire.stop();
1117
+ process.exit(0);
1118
+ }
1119
+
1120
+ // ─── Shared utils ─────────────────────────────────────────────────────────────
1121
+ function _cancelled(p) { p.cancel('Cancelled.'); process.exit(0); }
1122
+ function _noConfig(p) { p.cancel('No mongofire.config.js found — run npx mongofire init first.'); process.exit(1); }
1123
+
1124
+ function detectModuleSystem(cwd, override) {
1125
+ if (override && override !== 'auto') return override;
1126
+ const pkg = readPackageJson(cwd);
1127
+ if (pkg?.type === 'module') return 'esm';
1128
+ if (pkg?.type === 'commonjs') return 'cjs';
1129
+ const probes = ['index.js', 'app.js', 'server.js', 'main.js', path.join('src', 'index.js'), path.join('src', 'main.js')];
1130
+ for (const rel of probes) {
1131
+ const full = path.join(cwd, rel);
1132
+ if (!fs.existsSync(full)) continue;
1133
+ const code = fs.readFileSync(full, 'utf8');
1134
+ if (/\bimport\s.+from\s+['"]/.test(code) || /\bexport\s+default\b/.test(code)) return 'esm';
1135
+ if (/\brequire\(/.test(code) || /\bmodule\.exports\b/.test(code)) return 'cjs';
1136
+ }
1137
+ return 'cjs';
1138
+ }
1139
+
1140
+ function requireMongofire() {
1141
+ try { return require(path.join(__dirname, '..', 'dist', 'src', 'index.cjs')); } catch (_) {}
1142
+ try { return require(path.join(__dirname, '..', 'src', 'index.cjs')); } catch (err) {
1143
+ console.error('✖ MongoFire core load failed:', err.message); process.exit(1);
1144
+ }
1145
+ }
1146
+
1147
+ function resolveConfigPath(cwd) {
1148
+ for (const f of ['mongofire.config.js', 'mongofire.config.mjs', 'mongofire.config.cjs']) {
1149
+ const full = path.join(cwd, f);
1150
+ if (fs.existsSync(full)) return full;
1151
+ }
1152
+ return null;
1153
+ }
1154
+
1155
+ // AUTO-DETECT: always use dynamic import() for config files.
1156
+ // This works for CJS projects, ESM projects ("type":"module"), .cjs, .mjs,
1157
+ // and all Node 14+ versions without ERR_INTERNAL_ASSERTION or ERR_REQUIRE_ESM.
1158
+ // import() wraps CJS module.exports as { default: ... } — we unwrap it below.
1159
+ // There is no longer any need to branch on extension or package.json "type".
1160
+ async function loadConfig(configPath) {
1161
+ const mod = await import(pathToFileURL(configPath).href);
1162
+ return (mod.default !== undefined) ? mod.default : mod;
1163
+ }
1164
+
1165
+ // Kept as alias for any internal callers that still reference loadESM directly.
1166
+ async function loadESM(filePath) {
1167
+ return loadConfig(filePath);
1168
+ }
1169
+
1170
+ function readPackageJson(cwd) {
1171
+ const p = path.join(cwd, 'package.json');
1172
+ if (!fs.existsSync(p)) return null;
1173
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
1174
+ }
1175
+
1176
+ function escapeRE(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
1177
+
1178
+ // ─── Minimal fallback (if @clack/prompts not available) ───────────────────────
1179
+ function buildFallbackClack() {
1180
+ const readline = require('readline');
1181
+ let rl;
1182
+ function getRL() {
1183
+ if (!rl) {
1184
+ process.stdin.resume();
1185
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: IS_TTY });
1186
+ rl.on('error', () => {});
1187
+ }
1188
+ return rl;
1189
+ }
1190
+ function ask(q) {
1191
+ return new Promise((res) => {
1192
+ const r = getRL();
1193
+ r.once('close', () => res(''));
1194
+ r.question(q, (a) => res(a.trim()));
1195
+ });
1196
+ }
1197
+ return {
1198
+ isCancel: () => false,
1199
+ intro: (t) => console.log(`\n┌ ${t}\n│`),
1200
+ outro: (m) => { console.log(`│\n└ ${m}\n`); if (rl) rl.close(); },
1201
+ cancel: (m) => { console.log(`│\n└ ${m}\n`); if (rl) rl.close(); },
1202
+ note: (body, title) => { if (title) console.log(`│\n│ ${title}`); body.split('\n').forEach((l) => console.log(`│ ${l}`)); },
1203
+ spinner: () => ({ start: (m) => process.stdout.write(`│\n│ ${m} `), stop: (m) => console.log(`→ ${m}`) }),
1204
+ async select({ message, options, initialValue }) {
1205
+ console.log(`│\n◇ ${message}`);
1206
+ options.forEach((o, i) => {
1207
+ const hint = o.hint ? `\x1b[2m — ${o.hint}\x1b[0m` : '';
1208
+ const mark = o.value === initialValue ? ' \x1b[33m(default)\x1b[0m' : '';
1209
+ console.log(`│ ${i + 1}. ${o.label}${mark}${hint}`);
1210
+ });
1211
+ const def = options.findIndex((o) => o.value === initialValue);
1212
+ const ans = await ask(`│ Enter number [${def >= 0 ? def + 1 : 1}]: `);
1213
+ const idx = ans ? Math.max(0, parseInt(ans, 10) - 1) : (def >= 0 ? def : 0);
1214
+ return options[idx]?.value ?? options[0]?.value;
1215
+ },
1216
+ async multiselect({ message, options }) {
1217
+ console.log(`│\n◇ ${message} (comma-separated numbers, blank = none)`);
1218
+ options.forEach((o, i) => console.log(`│ ${i + 1}. ${o.label}${o.hint ? ' (' + o.hint + ')' : ''}`));
1219
+ const ans = await ask('│ Numbers: ');
1220
+ if (!ans.trim()) return [];
1221
+ return ans.split(',').map((s) => options[parseInt(s.trim(), 10) - 1]?.value).filter(Boolean);
1222
+ },
1223
+ async text({ message, defaultValue, placeholder }) {
1224
+ const hint = defaultValue ? ` (default: ${defaultValue})` : placeholder ? ` e.g. ${placeholder}` : '';
1225
+ const ans = await ask(`│\n◇ ${message}${hint}\n│ `);
1226
+ return (ans || '').trim() || defaultValue || '';
1227
+ },
1228
+ async confirm({ message, initialValue, hint }) {
1229
+ const defaultYes = initialValue !== false;
1230
+ if (hint) console.log(`│ \x1b[2m${hint}\x1b[0m`);
1231
+ const ans = await ask(`│\n◇ ${message} (${defaultYes ? 'Y/n' : 'y/N'}) `);
1232
+ const t = (ans || '').trim().toLowerCase();
1233
+ if (t === '') return defaultYes;
1234
+ return t === 'y' || t === 'yes';
1235
+ },
1236
+ };
1237
+ }