@nightowne/tas-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,1010 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * TAS CLI - Telegram as Storage
5
+ * Main command-line interface
6
+ */
7
+
8
+ import { Command } from 'commander';
9
+ import chalk from 'chalk';
10
+ import ora from 'ora';
11
+ import inquirer from 'inquirer';
12
+ import { TelegramClient } from './telegram/client.js';
13
+ import { Encryptor } from './crypto/encryption.js';
14
+ import { Compressor } from './utils/compression.js';
15
+ import { FileIndex } from './db/index.js';
16
+ import { processFile, retrieveFile } from './index.js';
17
+ import { printBanner, LOGO, TAGLINE, VERSION } from './utils/branding.js';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const DATA_DIR = path.join(__dirname, '..', 'data');
24
+
25
+ const program = new Command();
26
+
27
+ program
28
+ .name('tas')
29
+ .description(chalk.cyan('šŸ“¦ TAS') + chalk.dim(' - Telegram as Storage | Free • Encrypted • Unlimited'))
30
+ .version(VERSION)
31
+ .hook('preAction', (thisCommand) => {
32
+ // Show banner for main commands
33
+ if (['init', 'status'].includes(thisCommand.args[0])) {
34
+ printBanner();
35
+ }
36
+ });
37
+
38
+ // ============== INIT COMMAND ==============
39
+ program
40
+ .command('init')
41
+ .description('Initialize TAS and connect to Telegram')
42
+ .action(async () => {
43
+ console.log(chalk.cyan('\nšŸš€ Initializing Telegram as Storage...\n'));
44
+
45
+ // Ensure data directory exists
46
+ if (!fs.existsSync(DATA_DIR)) {
47
+ fs.mkdirSync(DATA_DIR, { recursive: true });
48
+ }
49
+
50
+ // Get bot token
51
+ console.log(chalk.yellow('šŸ“± First, create a Telegram bot:'));
52
+ console.log(chalk.dim(' 1. Open Telegram and message @BotFather'));
53
+ console.log(chalk.dim(' 2. Send /newbot and follow the prompts'));
54
+ console.log(chalk.dim(' 3. Copy the bot token\n'));
55
+
56
+ const { token } = await inquirer.prompt([
57
+ {
58
+ type: 'password',
59
+ name: 'token',
60
+ message: 'Enter your Telegram bot token:',
61
+ mask: '*',
62
+ validate: (input) => input.includes(':') || 'Invalid token format (should contain :)'
63
+ }
64
+ ]);
65
+
66
+ // Get encryption password
67
+ const { password } = await inquirer.prompt([
68
+ {
69
+ type: 'password',
70
+ name: 'password',
71
+ message: 'Set your encryption password (used for all files):',
72
+ mask: '*',
73
+ validate: (input) => input.length >= 8 || 'Password must be at least 8 characters'
74
+ }
75
+ ]);
76
+
77
+ const { confirmPassword } = await inquirer.prompt([
78
+ {
79
+ type: 'password',
80
+ name: 'confirmPassword',
81
+ message: 'Confirm password:',
82
+ mask: '*',
83
+ validate: (input) => input === password || 'Passwords do not match'
84
+ }
85
+ ]);
86
+
87
+ // Initialize encryption
88
+ const encryptor = new Encryptor(password);
89
+
90
+ // Initialize Telegram
91
+ const spinner = ora('Connecting to Telegram...').start();
92
+ const client = new TelegramClient(DATA_DIR);
93
+
94
+ try {
95
+ const botInfo = await client.initialize(token);
96
+ spinner.succeed(`Connected as @${botInfo.username}`);
97
+
98
+ // Wait for user to message the bot
99
+ console.log(chalk.yellow(`\nšŸ“© Now message your bot @${botInfo.username} on Telegram`));
100
+ console.log(chalk.dim(' (Just send any message to link your account)\n'));
101
+
102
+ spinner.start('Waiting for your message...');
103
+ const userInfo = await client.waitForChatId(120000);
104
+ spinner.succeed(`Linked to ${userInfo.firstName} (@${userInfo.username})`);
105
+
106
+ // Save config
107
+ const configPath = path.join(DATA_DIR, 'config.json');
108
+ fs.writeFileSync(configPath, JSON.stringify({
109
+ botToken: token,
110
+ chatId: userInfo.chatId,
111
+ passwordHash: encryptor.getPasswordHash(),
112
+ username: userInfo.username,
113
+ createdAt: new Date().toISOString()
114
+ }, null, 2));
115
+
116
+ // Initialize database
117
+ spinner.start('Initializing local index...');
118
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
119
+ db.init();
120
+ spinner.succeed('Local index ready');
121
+
122
+ // Send welcome message
123
+ await client.bot.sendMessage(userInfo.chatId,
124
+ 'šŸ“¦ *TAS - Telegram as Storage*\n\n' +
125
+ 'āœ… Setup complete! This chat will store your encrypted files.\n\n' +
126
+ '_Do not delete messages in this chat._',
127
+ { parse_mode: 'Markdown' }
128
+ );
129
+
130
+ console.log(chalk.cyan('\nšŸŽ‰ TAS is ready! Use `tas push <file>` to upload files.\n'));
131
+
132
+ } catch (err) {
133
+ spinner.fail(`Telegram initialization failed: ${err.message}`);
134
+ process.exit(1);
135
+ }
136
+ });
137
+
138
+ // ============== PUSH COMMAND ==============
139
+ program
140
+ .command('push <file>')
141
+ .description('Upload a file to Telegram storage')
142
+ .option('-n, --name <name>', 'Custom name for the file')
143
+ .action(async (file, options) => {
144
+ const spinner = ora('Preparing...').start();
145
+
146
+ try {
147
+ // Check file exists
148
+ if (!fs.existsSync(file)) {
149
+ spinner.fail(`File not found: ${file}`);
150
+ process.exit(1);
151
+ }
152
+
153
+ // Load config
154
+ const configPath = path.join(DATA_DIR, 'config.json');
155
+ if (!fs.existsSync(configPath)) {
156
+ spinner.fail('TAS not initialized. Run `tas init` first.');
157
+ process.exit(1);
158
+ }
159
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
160
+
161
+ spinner.stop();
162
+
163
+ // Get password
164
+ const { password } = await inquirer.prompt([
165
+ {
166
+ type: 'password',
167
+ name: 'password',
168
+ message: 'Enter your encryption password:',
169
+ mask: '*'
170
+ }
171
+ ]);
172
+
173
+ // Verify password
174
+ const encryptor = new Encryptor(password);
175
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
176
+ console.log(chalk.red('āœ— Incorrect password'));
177
+ process.exit(1);
178
+ }
179
+
180
+ spinner.start('Processing file...');
181
+
182
+ // Process and upload
183
+ const result = await processFile(file, {
184
+ password,
185
+ dataDir: DATA_DIR,
186
+ customName: options.name,
187
+ config,
188
+ onProgress: (msg) => { spinner.text = msg; }
189
+ });
190
+
191
+ spinner.succeed(`Uploaded: ${chalk.green(result.filename)}`);
192
+ console.log(chalk.dim(` Hash: ${result.hash}`));
193
+ console.log(chalk.dim(` Size: ${formatBytes(result.originalSize)} → ${formatBytes(result.storedSize)}`));
194
+ console.log(chalk.dim(` Chunks: ${result.chunks}`));
195
+
196
+ } catch (err) {
197
+ spinner.fail(`Upload failed: ${err.message}`);
198
+ process.exit(1);
199
+ }
200
+ });
201
+
202
+ // ============== PULL COMMAND ==============
203
+ program
204
+ .command('pull <identifier>')
205
+ .description('Download a file from Telegram storage (by filename or hash)')
206
+ .option('-o, --output <path>', 'Output path for the file')
207
+ .action(async (identifier, options) => {
208
+ const spinner = ora('Looking up file...').start();
209
+
210
+ try {
211
+ // Load config
212
+ const configPath = path.join(DATA_DIR, 'config.json');
213
+ if (!fs.existsSync(configPath)) {
214
+ spinner.fail('TAS not initialized. Run `tas init` first.');
215
+ process.exit(1);
216
+ }
217
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
218
+
219
+ // Find file in index
220
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
221
+ db.init();
222
+
223
+ let fileRecord = db.findByHash(identifier) || db.findByName(identifier);
224
+ if (!fileRecord) {
225
+ spinner.fail(`File not found: ${identifier}`);
226
+ process.exit(1);
227
+ }
228
+
229
+ spinner.stop();
230
+
231
+ // Get password
232
+ const { password } = await inquirer.prompt([
233
+ {
234
+ type: 'password',
235
+ name: 'password',
236
+ message: 'Enter your encryption password:',
237
+ mask: '*'
238
+ }
239
+ ]);
240
+
241
+ // Verify password
242
+ const encryptor = new Encryptor(password);
243
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
244
+ console.log(chalk.red('āœ— Incorrect password'));
245
+ process.exit(1);
246
+ }
247
+
248
+ spinner.start('Downloading...');
249
+
250
+ const outputPath = options.output || fileRecord.filename;
251
+ await retrieveFile(fileRecord, {
252
+ password,
253
+ dataDir: DATA_DIR,
254
+ outputPath,
255
+ config,
256
+ onProgress: (msg) => { spinner.text = msg; }
257
+ });
258
+
259
+ spinner.succeed(`Downloaded: ${chalk.green(outputPath)}`);
260
+
261
+ } catch (err) {
262
+ spinner.fail(`Download failed: ${err.message}`);
263
+ process.exit(1);
264
+ }
265
+ });
266
+
267
+ // ============== LIST COMMAND ==============
268
+ program
269
+ .command('list')
270
+ .alias('ls')
271
+ .description('List all stored files')
272
+ .option('-l, --long', 'Show detailed information')
273
+ .action(async (options) => {
274
+ try {
275
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
276
+ db.init();
277
+
278
+ const files = db.listAll();
279
+
280
+ if (files.length === 0) {
281
+ console.log(chalk.yellow('\nšŸ“­ No files stored yet. Use `tas push <file>` to upload.\n'));
282
+ return;
283
+ }
284
+
285
+ console.log(chalk.cyan(`\nšŸ“¦ Stored Files (${files.length})\n`));
286
+
287
+ if (options.long) {
288
+ console.log(chalk.dim('HASH'.padEnd(16) + 'SIZE'.padEnd(12) + 'CHUNKS'.padEnd(8) + 'DATE'.padEnd(12) + 'FILENAME'));
289
+ console.log(chalk.dim('─'.repeat(70)));
290
+
291
+ for (const file of files) {
292
+ const hash = file.hash.substring(0, 12) + '...';
293
+ const size = formatBytes(file.original_size).padEnd(12);
294
+ const chunks = String(file.chunks).padEnd(8);
295
+ const date = new Date(file.created_at).toLocaleDateString().padEnd(12);
296
+ console.log(`${chalk.dim(hash.padEnd(16))}${size}${chunks}${date}${chalk.white(file.filename)}`);
297
+ }
298
+ } else {
299
+ for (const file of files) {
300
+ console.log(` ${chalk.blue('ā—')} ${file.filename} ${chalk.dim(`(${formatBytes(file.original_size)})`)}`);
301
+ }
302
+ }
303
+
304
+ console.log();
305
+
306
+ } catch (err) {
307
+ console.error(chalk.red('Error listing files:'), err.message);
308
+ process.exit(1);
309
+ }
310
+ });
311
+
312
+ // ============== DELETE COMMAND ==============
313
+ program
314
+ .command('delete <identifier>')
315
+ .alias('rm')
316
+ .description('Remove a file from the index (optionally from Telegram too)')
317
+ .option('--hard', 'Also delete from Telegram')
318
+ .action(async (identifier, options) => {
319
+ try {
320
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
321
+ db.init();
322
+
323
+ let fileRecord = db.findByHash(identifier) || db.findByName(identifier);
324
+ if (!fileRecord) {
325
+ console.log(chalk.red(`āœ— File not found: ${identifier}`));
326
+ process.exit(1);
327
+ }
328
+
329
+ const { confirm } = await inquirer.prompt([
330
+ {
331
+ type: 'confirm',
332
+ name: 'confirm',
333
+ message: `Delete "${fileRecord.filename}" from index${options.hard ? ' and Telegram' : ''}?`,
334
+ default: false
335
+ }
336
+ ]);
337
+
338
+ if (confirm) {
339
+ // If hard delete, also remove from Telegram
340
+ if (options.hard) {
341
+ const configPath = path.join(DATA_DIR, 'config.json');
342
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
343
+
344
+ const client = new TelegramClient(DATA_DIR);
345
+ await client.initialize(config.botToken);
346
+ client.setChatId(config.chatId);
347
+
348
+ const chunks = db.getChunks(fileRecord.id);
349
+ for (const chunk of chunks) {
350
+ await client.deleteMessage(chunk.message_id);
351
+ }
352
+ }
353
+
354
+ db.delete(fileRecord.id);
355
+ console.log(chalk.green(`āœ“ Removed "${fileRecord.filename}"`));
356
+ }
357
+
358
+ } catch (err) {
359
+ console.error(chalk.red('Error deleting file:'), err.message);
360
+ process.exit(1);
361
+ }
362
+ });
363
+
364
+ // ============== STATUS COMMAND ==============
365
+ program
366
+ .command('status')
367
+ .description('Show TAS status and statistics')
368
+ .action(async () => {
369
+ const configPath = path.join(DATA_DIR, 'config.json');
370
+
371
+ if (!fs.existsSync(configPath)) {
372
+ console.log(chalk.yellow('\nāš ļø TAS not initialized. Run `tas init` first.\n'));
373
+ return;
374
+ }
375
+
376
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
377
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
378
+ db.init();
379
+
380
+ const files = db.listAll();
381
+ const totalSize = files.reduce((acc, f) => acc + f.original_size, 0);
382
+ const storedSize = files.reduce((acc, f) => acc + f.stored_size, 0);
383
+ const savings = totalSize > 0 ? Math.round((1 - storedSize / totalSize) * 100) : 0;
384
+
385
+ console.log(chalk.cyan('\nšŸ“Š TAS Status\n'));
386
+ console.log(` Initialized: ${chalk.white(new Date(config.createdAt).toLocaleDateString())}`);
387
+ console.log(` Telegram user: ${chalk.white('@' + (config.username || 'unknown'))}`);
388
+ console.log(` Files stored: ${chalk.white(files.length)}`);
389
+ console.log(` Total size: ${chalk.white(formatBytes(totalSize))}`);
390
+ console.log(` Compressed: ${chalk.white(formatBytes(storedSize))} ${chalk.dim(`(${savings}% saved)`)}`);
391
+ console.log();
392
+ });
393
+
394
+ // ============== MOUNT COMMAND ==============
395
+ program
396
+ .command('mount <mountpoint>')
397
+ .description('šŸ”„ Mount Telegram storage as a local folder (FUSE)')
398
+ .action(async (mountpoint) => {
399
+ console.log(chalk.cyan('\nšŸ—‚ļø Mounting Telegram as filesystem...\n'));
400
+
401
+ // Load config
402
+ const configPath = path.join(DATA_DIR, 'config.json');
403
+ if (!fs.existsSync(configPath)) {
404
+ console.log(chalk.red('āœ— TAS not initialized. Run `tas init` first.'));
405
+ process.exit(1);
406
+ }
407
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
408
+
409
+ // Get password
410
+ const { password } = await inquirer.prompt([
411
+ {
412
+ type: 'password',
413
+ name: 'password',
414
+ message: 'Enter your encryption password:',
415
+ mask: '*'
416
+ }
417
+ ]);
418
+
419
+ // Verify password
420
+ const encryptor = new Encryptor(password);
421
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
422
+ console.log(chalk.red('āœ— Incorrect password'));
423
+ process.exit(1);
424
+ }
425
+
426
+ const spinner = ora('Initializing filesystem...').start();
427
+
428
+ try {
429
+ // Resolve mount point to absolute path
430
+ const absMount = path.resolve(mountpoint);
431
+
432
+ // Dynamic import to avoid loading fuse-native if not needed
433
+ const { TelegramFS } = await import('./fuse/mount.js');
434
+
435
+ const tfs = new TelegramFS({
436
+ dataDir: DATA_DIR,
437
+ password,
438
+ config,
439
+ mountPoint: absMount
440
+ });
441
+
442
+ await tfs.initialize();
443
+ await tfs.mount();
444
+
445
+ spinner.succeed(`Mounted at ${chalk.green(absMount)}`);
446
+
447
+ console.log(chalk.cyan('\nšŸ“ Telegram storage is now a folder!\n'));
448
+ console.log(chalk.dim(' Commands you can use:'));
449
+ console.log(chalk.dim(` ls ${absMount} # List files`));
450
+ console.log(chalk.dim(` cp file.pdf ${absMount}/ # Upload`));
451
+ console.log(chalk.dim(` cat ${absMount}/file.txt # Read`));
452
+ console.log(chalk.dim(` rm ${absMount}/file.pdf # Delete`));
453
+ console.log();
454
+ console.log(chalk.yellow('Press Ctrl+C to unmount'));
455
+
456
+ // Handle graceful shutdown
457
+ const cleanup = async () => {
458
+ console.log(chalk.dim('\n\nUnmounting...'));
459
+ await tfs.unmount();
460
+ console.log(chalk.green('āœ“ Unmounted successfully'));
461
+ process.exit(0);
462
+ };
463
+
464
+ process.on('SIGINT', cleanup);
465
+ process.on('SIGTERM', cleanup);
466
+
467
+ // Keep process running
468
+ await new Promise(() => { });
469
+
470
+ } catch (err) {
471
+ spinner.fail(`Mount failed: ${err.message}`);
472
+ console.log(chalk.dim('\nNote: FUSE requires libfuse to be installed:'));
473
+ console.log(chalk.dim(' Ubuntu/Debian: sudo apt install fuse libfuse-dev'));
474
+ console.log(chalk.dim(' Fedora: sudo dnf install fuse fuse-devel'));
475
+ console.log(chalk.dim(' macOS: brew install macfuse\n'));
476
+ process.exit(1);
477
+ }
478
+ });
479
+
480
+ // ============== UNMOUNT COMMAND ==============
481
+ program
482
+ .command('unmount <mountpoint>')
483
+ .alias('umount')
484
+ .description('Unmount a previously mounted Telegram folder')
485
+ .action(async (mountpoint) => {
486
+ const absMount = path.resolve(mountpoint);
487
+
488
+ const spinner = ora('Unmounting...').start();
489
+
490
+ try {
491
+ const { execSync } = await import('child_process');
492
+
493
+ // Use fusermount on Linux, umount on macOS
494
+ const isMac = process.platform === 'darwin';
495
+ const cmd = isMac ? `umount "${absMount}"` : `fusermount -u "${absMount}"`;
496
+
497
+ execSync(cmd, { stdio: 'pipe' });
498
+
499
+ spinner.succeed(`Unmounted ${chalk.green(absMount)}`);
500
+ } catch (err) {
501
+ spinner.fail(`Unmount failed: ${err.message}`);
502
+ console.log(chalk.dim('\nTry: fusermount -u ' + absMount));
503
+ process.exit(1);
504
+ }
505
+ });
506
+
507
+ // ============== TAG COMMAND ==============
508
+ const tagCmd = program
509
+ .command('tag')
510
+ .description('Manage file tags');
511
+
512
+ tagCmd
513
+ .command('add <file> <tags...>')
514
+ .description('Add tags to a file')
515
+ .action(async (file, tags) => {
516
+ try {
517
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
518
+ db.init();
519
+
520
+ const fileRecord = db.findByHash(file) || db.findByName(file);
521
+ if (!fileRecord) {
522
+ console.log(chalk.red(`āœ— File not found: ${file}`));
523
+ process.exit(1);
524
+ }
525
+
526
+ for (const tag of tags) {
527
+ db.addTag(fileRecord.id, tag);
528
+ }
529
+
530
+ const allTags = db.getFileTags(fileRecord.id);
531
+ console.log(chalk.green(`āœ“ Tags updated for "${fileRecord.filename}"`));
532
+ console.log(chalk.dim(` Tags: ${allTags.join(', ')}`));
533
+
534
+ db.close();
535
+ } catch (err) {
536
+ console.error(chalk.red('Error:'), err.message);
537
+ process.exit(1);
538
+ }
539
+ });
540
+
541
+ tagCmd
542
+ .command('remove <file> <tags...>')
543
+ .description('Remove tags from a file')
544
+ .action(async (file, tags) => {
545
+ try {
546
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
547
+ db.init();
548
+
549
+ const fileRecord = db.findByHash(file) || db.findByName(file);
550
+ if (!fileRecord) {
551
+ console.log(chalk.red(`āœ— File not found: ${file}`));
552
+ process.exit(1);
553
+ }
554
+
555
+ for (const tag of tags) {
556
+ db.removeTag(fileRecord.id, tag);
557
+ }
558
+
559
+ const allTags = db.getFileTags(fileRecord.id);
560
+ console.log(chalk.green(`āœ“ Tags updated for "${fileRecord.filename}"`));
561
+ console.log(chalk.dim(` Tags: ${allTags.length > 0 ? allTags.join(', ') : '(none)'}`));
562
+
563
+ db.close();
564
+ } catch (err) {
565
+ console.error(chalk.red('Error:'), err.message);
566
+ process.exit(1);
567
+ }
568
+ });
569
+
570
+ tagCmd
571
+ .command('list [tag]')
572
+ .description('List all tags, or files with a specific tag')
573
+ .action(async (tag) => {
574
+ try {
575
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
576
+ db.init();
577
+
578
+ if (tag) {
579
+ // List files with this tag
580
+ const files = db.findByTag(tag);
581
+ if (files.length === 0) {
582
+ console.log(chalk.yellow(`\nšŸ“­ No files with tag "${tag}"\n`));
583
+ } else {
584
+ console.log(chalk.cyan(`\nšŸ·ļø Files tagged "${tag}" (${files.length})\n`));
585
+ for (const file of files) {
586
+ console.log(` ${chalk.blue('ā—')} ${file.filename} ${chalk.dim(`(${formatBytes(file.original_size)})`)}`);
587
+ }
588
+ console.log();
589
+ }
590
+ } else {
591
+ // List all tags
592
+ const tags = db.getAllTags();
593
+ if (tags.length === 0) {
594
+ console.log(chalk.yellow('\nšŸ“­ No tags created yet. Use `tas tag add <file> <tag>` to add tags.\n'));
595
+ } else {
596
+ console.log(chalk.cyan(`\nšŸ·ļø All Tags (${tags.length})\n`));
597
+ for (const t of tags) {
598
+ console.log(` ${chalk.blue('ā—')} ${t.tag} ${chalk.dim(`(${t.count} file${t.count > 1 ? 's' : ''})`)}`);
599
+ }
600
+ console.log();
601
+ }
602
+ }
603
+
604
+ db.close();
605
+ } catch (err) {
606
+ console.error(chalk.red('Error:'), err.message);
607
+ process.exit(1);
608
+ }
609
+ });
610
+
611
+ // ============== SYNC COMMAND ==============
612
+ const syncCmd = program
613
+ .command('sync')
614
+ .description('Folder sync (Dropbox-like auto-sync)');
615
+
616
+ syncCmd
617
+ .command('add <folder>')
618
+ .description('Register a folder for sync')
619
+ .action(async (folder) => {
620
+ try {
621
+ const absPath = path.resolve(folder);
622
+
623
+ if (!fs.existsSync(absPath)) {
624
+ console.log(chalk.red(`āœ— Folder not found: ${absPath}`));
625
+ process.exit(1);
626
+ }
627
+
628
+ if (!fs.statSync(absPath).isDirectory()) {
629
+ console.log(chalk.red(`āœ— Not a directory: ${absPath}`));
630
+ process.exit(1);
631
+ }
632
+
633
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
634
+ db.init();
635
+
636
+ db.addSyncFolder(absPath);
637
+ console.log(chalk.green(`āœ“ Added sync folder: ${absPath}`));
638
+ console.log(chalk.dim(' Use `tas sync start` to begin syncing'));
639
+
640
+ db.close();
641
+ } catch (err) {
642
+ console.error(chalk.red('Error:'), err.message);
643
+ process.exit(1);
644
+ }
645
+ });
646
+
647
+ syncCmd
648
+ .command('remove <folder>')
649
+ .description('Remove a folder from sync')
650
+ .action(async (folder) => {
651
+ try {
652
+ const absPath = path.resolve(folder);
653
+
654
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
655
+ db.init();
656
+
657
+ db.removeSyncFolder(absPath);
658
+ console.log(chalk.green(`āœ“ Removed sync folder: ${absPath}`));
659
+
660
+ db.close();
661
+ } catch (err) {
662
+ console.error(chalk.red('Error:'), err.message);
663
+ process.exit(1);
664
+ }
665
+ });
666
+
667
+ syncCmd
668
+ .command('status')
669
+ .description('Show sync status')
670
+ .action(async () => {
671
+ try {
672
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
673
+ db.init();
674
+
675
+ const folders = db.getSyncFolders();
676
+
677
+ if (folders.length === 0) {
678
+ console.log(chalk.yellow('\nšŸ“­ No folders registered for sync.'));
679
+ console.log(chalk.dim(' Use `tas sync add <folder>` to add a folder.\n'));
680
+ } else {
681
+ console.log(chalk.cyan(`\nšŸ“ Sync Folders (${folders.length})\n`));
682
+ for (const folder of folders) {
683
+ const states = db.getFolderSyncStates(folder.id);
684
+ const status = folder.enabled ? chalk.green('enabled') : chalk.dim('disabled');
685
+ console.log(` ${chalk.blue('ā—')} ${folder.local_path}`);
686
+ console.log(chalk.dim(` Status: ${status} | Files tracked: ${states.length}`));
687
+ }
688
+ console.log();
689
+ }
690
+
691
+ db.close();
692
+ } catch (err) {
693
+ console.error(chalk.red('Error:'), err.message);
694
+ process.exit(1);
695
+ }
696
+ });
697
+
698
+ syncCmd
699
+ .command('start')
700
+ .description('Start syncing all registered folders')
701
+ .action(async () => {
702
+ console.log(chalk.cyan('\nšŸ”„ Starting folder sync...\n'));
703
+
704
+ // Load config
705
+ const configPath = path.join(DATA_DIR, 'config.json');
706
+ if (!fs.existsSync(configPath)) {
707
+ console.log(chalk.red('āœ— TAS not initialized. Run `tas init` first.'));
708
+ process.exit(1);
709
+ }
710
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
711
+
712
+ // Get password
713
+ const { password } = await inquirer.prompt([
714
+ {
715
+ type: 'password',
716
+ name: 'password',
717
+ message: 'Enter your encryption password:',
718
+ mask: '*'
719
+ }
720
+ ]);
721
+
722
+ // Verify password
723
+ const encryptor = new Encryptor(password);
724
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
725
+ console.log(chalk.red('āœ— Incorrect password'));
726
+ process.exit(1);
727
+ }
728
+
729
+ try {
730
+ const { SyncEngine } = await import('./sync/sync.js');
731
+
732
+ const syncEngine = new SyncEngine({
733
+ dataDir: DATA_DIR,
734
+ password,
735
+ config
736
+ });
737
+
738
+ await syncEngine.initialize();
739
+
740
+ // Set up event handlers
741
+ syncEngine.on('sync-start', ({ folder }) => {
742
+ console.log(chalk.blue(`šŸ“‚ Scanning: ${folder}`));
743
+ });
744
+
745
+ syncEngine.on('sync-complete', ({ folder, uploaded, skipped }) => {
746
+ console.log(chalk.green(`āœ“ Synced: ${uploaded} uploaded, ${skipped} unchanged`));
747
+ });
748
+
749
+ syncEngine.on('file-upload-start', ({ file }) => {
750
+ console.log(chalk.dim(` ↑ Uploading: ${file}`));
751
+ });
752
+
753
+ syncEngine.on('file-upload-complete', ({ file }) => {
754
+ console.log(chalk.green(` āœ“ Uploaded: ${file}`));
755
+ });
756
+
757
+ syncEngine.on('file-upload-error', ({ file, error }) => {
758
+ console.log(chalk.red(` āœ— Failed: ${file} - ${error}`));
759
+ });
760
+
761
+ syncEngine.on('watch-start', ({ folder }) => {
762
+ console.log(chalk.cyan(`šŸ‘ļø Watching: ${folder}`));
763
+ });
764
+
765
+ // Start syncing
766
+ await syncEngine.start();
767
+
768
+ console.log(chalk.cyan('\n✨ Sync active! Watching for changes...'));
769
+ console.log(chalk.yellow('Press Ctrl+C to stop\n'));
770
+
771
+ // Handle graceful shutdown
772
+ const cleanup = () => {
773
+ console.log(chalk.dim('\n\nStopping sync...'));
774
+ syncEngine.stop();
775
+ console.log(chalk.green('āœ“ Sync stopped'));
776
+ process.exit(0);
777
+ };
778
+
779
+ process.on('SIGINT', cleanup);
780
+ process.on('SIGTERM', cleanup);
781
+
782
+ // Keep process running
783
+ await new Promise(() => { });
784
+
785
+ } catch (err) {
786
+ console.error(chalk.red('Sync failed:'), err.message);
787
+ process.exit(1);
788
+ }
789
+ });
790
+
791
+ syncCmd
792
+ .command('pull')
793
+ .description('Download all Telegram files to sync folders (two-way sync)')
794
+ .action(async () => {
795
+ console.log(chalk.cyan('\nšŸ“„ Pulling files from Telegram...\n'));
796
+
797
+ // Load config
798
+ const configPath = path.join(DATA_DIR, 'config.json');
799
+ if (!fs.existsSync(configPath)) {
800
+ console.log(chalk.red('āœ— TAS not initialized. Run `tas init` first.'));
801
+ process.exit(1);
802
+ }
803
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
804
+
805
+ // Get password
806
+ const { password } = await inquirer.prompt([
807
+ {
808
+ type: 'password',
809
+ name: 'password',
810
+ message: 'Enter your encryption password:',
811
+ mask: '*'
812
+ }
813
+ ]);
814
+
815
+ // Verify password
816
+ const encryptor = new Encryptor(password);
817
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
818
+ console.log(chalk.red('āœ— Incorrect password'));
819
+ process.exit(1);
820
+ }
821
+
822
+ const spinner = ora('Loading...').start();
823
+
824
+ try {
825
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
826
+ db.init();
827
+
828
+ const folders = db.getSyncFolders();
829
+ if (folders.length === 0) {
830
+ spinner.warn('No sync folders registered. Use `tas sync add <folder>` first.');
831
+ process.exit(0);
832
+ }
833
+
834
+ // Get all files from Telegram index
835
+ const files = db.listAll();
836
+ if (files.length === 0) {
837
+ spinner.info('No files in Telegram storage.');
838
+ process.exit(0);
839
+ }
840
+
841
+ spinner.succeed(`Found ${files.length} files in Telegram`);
842
+
843
+ // Download each file that matches a sync folder
844
+ let downloaded = 0;
845
+ let skipped = 0;
846
+
847
+ for (const file of files) {
848
+ // Check if file belongs to any sync folder (by name prefix)
849
+ for (const folder of folders) {
850
+ const folderName = path.basename(folder.local_path);
851
+ const targetPath = path.join(folder.local_path, file.filename);
852
+
853
+ // Check if file already exists locally with same hash
854
+ if (fs.existsSync(targetPath)) {
855
+ skipped++;
856
+ continue;
857
+ }
858
+
859
+ // Ensure directory exists
860
+ const targetDir = path.dirname(targetPath);
861
+ if (!fs.existsSync(targetDir)) {
862
+ fs.mkdirSync(targetDir, { recursive: true });
863
+ }
864
+
865
+ console.log(chalk.dim(` ↓ Downloading: ${file.filename}`));
866
+
867
+ try {
868
+ await retrieveFile(file, {
869
+ password,
870
+ dataDir: DATA_DIR,
871
+ outputPath: targetPath,
872
+ config,
873
+ onProgress: () => { }
874
+ });
875
+
876
+ // Update sync state
877
+ const { hashFile } = await import('./crypto/encryption.js');
878
+ const hash = await hashFile(targetPath);
879
+ const stats = fs.statSync(targetPath);
880
+ db.updateSyncState(folder.id, file.filename, hash, stats.mtimeMs);
881
+
882
+ console.log(chalk.green(` āœ“ Downloaded: ${file.filename}`));
883
+ downloaded++;
884
+ } catch (err) {
885
+ console.log(chalk.red(` āœ— Failed: ${file.filename} - ${err.message}`));
886
+ }
887
+
888
+ break; // Only download to first matching folder
889
+ }
890
+ }
891
+
892
+ console.log(chalk.green(`\nāœ“ Pull complete: ${downloaded} downloaded, ${skipped} skipped\n`));
893
+
894
+ db.close();
895
+ } catch (err) {
896
+ spinner.fail(`Pull failed: ${err.message}`);
897
+ process.exit(1);
898
+ }
899
+ });
900
+
901
+ // ============== VERIFY COMMAND ==============
902
+ program
903
+ .command('verify')
904
+ .description('Verify file integrity and check for missing Telegram messages')
905
+ .action(async () => {
906
+ console.log(chalk.cyan('\nšŸ” Verifying file integrity...\n'));
907
+
908
+ // Load config
909
+ const configPath = path.join(DATA_DIR, 'config.json');
910
+ if (!fs.existsSync(configPath)) {
911
+ console.log(chalk.red('āœ— TAS not initialized. Run `tas init` first.'));
912
+ process.exit(1);
913
+ }
914
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
915
+
916
+ const spinner = ora('Checking files...').start();
917
+
918
+ try {
919
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
920
+ db.init();
921
+
922
+ const files = db.listAll();
923
+ if (files.length === 0) {
924
+ spinner.info('No files in storage.');
925
+ process.exit(0);
926
+ }
927
+
928
+ spinner.text = 'Connecting to Telegram...';
929
+
930
+ const client = new TelegramClient(DATA_DIR);
931
+ await client.initialize(config.botToken);
932
+ client.setChatId(config.chatId);
933
+
934
+ spinner.succeed(`Checking ${files.length} files...`);
935
+
936
+ let valid = 0;
937
+ let missing = 0;
938
+ let errors = [];
939
+
940
+ for (const file of files) {
941
+ const chunks = db.getChunks(file.id);
942
+ let fileValid = true;
943
+
944
+ for (const chunk of chunks) {
945
+ try {
946
+ // Try to get file info from Telegram
947
+ if (!chunk.file_telegram_id) {
948
+ fileValid = false;
949
+ errors.push({ file: file.filename, error: 'Missing Telegram file ID' });
950
+ break;
951
+ }
952
+
953
+ // Check if file is accessible (will throw if deleted)
954
+ await client.bot.getFile(chunk.file_telegram_id);
955
+ } catch (err) {
956
+ fileValid = false;
957
+ errors.push({
958
+ file: file.filename,
959
+ chunk: chunk.chunk_index,
960
+ error: err.message.includes('file') ? 'File deleted from Telegram' : err.message
961
+ });
962
+ break;
963
+ }
964
+ }
965
+
966
+ if (fileValid) {
967
+ console.log(` ${chalk.green('āœ“')} ${file.filename}`);
968
+ valid++;
969
+ } else {
970
+ console.log(` ${chalk.red('āœ—')} ${file.filename} ${chalk.dim('(missing)')}`);
971
+ missing++;
972
+ }
973
+ }
974
+
975
+ console.log();
976
+ console.log(chalk.cyan('šŸ“Š Verification Results'));
977
+ console.log(` Valid: ${chalk.green(valid)}`);
978
+ console.log(` Missing: ${chalk.red(missing)}`);
979
+
980
+ if (errors.length > 0) {
981
+ console.log(chalk.yellow('\nāš ļø Issues found:'));
982
+ for (const err of errors) {
983
+ console.log(chalk.dim(` ${err.file}: ${err.error}`));
984
+ }
985
+ console.log(chalk.dim('\n Tip: Re-upload missing files with `tas push <file>`'));
986
+ } else {
987
+ console.log(chalk.green('\n✨ All files intact!'));
988
+ }
989
+
990
+ console.log();
991
+ db.close();
992
+
993
+ } catch (err) {
994
+ spinner.fail(`Verification failed: ${err.message}`);
995
+ process.exit(1);
996
+ }
997
+ });
998
+
999
+ // Helper function
1000
+ function formatBytes(bytes) {
1001
+ if (bytes === 0) return '0 B';
1002
+ const k = 1024;
1003
+ const sizes = ['B', 'KB', 'MB', 'GB'];
1004
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1005
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1006
+ }
1007
+
1008
+ program.parse();
1009
+
1010
+