@nightowne/tas-cli 2.4.1 → 3.0.1

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 CHANGED
@@ -10,18 +10,61 @@ import chalk from 'chalk';
10
10
  import ora from 'ora';
11
11
  import inquirer from 'inquirer';
12
12
  import { TelegramClient } from './telegram/client.js';
13
+ import { TelegramPool, MULTI_BOT_WARNING } from './telegram/pool.js';
13
14
  import { Encryptor } from './crypto/encryption.js';
14
15
  import { Compressor } from './utils/compression.js';
15
16
  import { FileIndex } from './db/index.js';
16
17
  import { processFile, retrieveFile } from './index.js';
18
+ import { backupRemoteManifest, downloadRemoteManifest } from './manifest.js';
17
19
  import { printBanner, LOGO, TAGLINE, VERSION } from './utils/branding.js';
18
- import { getPassword, verifyPassword, loadConfig, requireConfig, getAndVerifyPassword, decryptBotToken, resolveConfig } from './utils/cli-helpers.js';
20
+ import {
21
+ getPassword,
22
+ verifyPassword,
23
+ loadConfig,
24
+ requireConfig,
25
+ getAndVerifyPassword,
26
+ resolveConfig,
27
+ getBotEntries,
28
+ encryptBotToken,
29
+ saveConfig
30
+ } from './utils/cli-helpers.js';
19
31
  import fs from 'fs';
20
32
  import path from 'path';
21
33
  import os from 'os';
22
34
 
23
35
  const DATA_DIR = process.env.TAS_DATA_DIR || path.join(os.homedir(), '.tas');
24
36
 
37
+ function normalizeBotId(value) {
38
+ return String(value || 'bot')
39
+ .toLowerCase()
40
+ .replace(/^@/, '')
41
+ .replace(/[^a-z0-9_-]+/g, '-')
42
+ .replace(/^-+|-+$/g, '')
43
+ .slice(0, 32) || 'bot';
44
+ }
45
+
46
+ function warnIfMultiBot(config) {
47
+ if ((config.bots || []).filter(bot => bot.enabled !== false).length > 1) {
48
+ console.log(chalk.yellow(`⚠ ${MULTI_BOT_WARNING}\n`));
49
+ }
50
+ }
51
+
52
+ function migrateConfigToV3(rawConfig, password) {
53
+ const bots = getBotEntries(rawConfig).map(bot => ({
54
+ id: bot.id,
55
+ encryptedBotToken: bot.encryptedBotToken || encryptBotToken(bot.botToken, password),
56
+ chatId: bot.chatId,
57
+ username: bot.username,
58
+ enabled: bot.enabled !== false,
59
+ createdAt: bot.createdAt || new Date().toISOString()
60
+ }));
61
+ const migrated = { ...rawConfig, bots, configVersion: 3 };
62
+ delete migrated.botToken;
63
+ delete migrated.encryptedBotToken;
64
+ delete migrated.chatId;
65
+ return migrated;
66
+ }
67
+
25
68
  // Global error handlers — prevent silent crashes
26
69
  process.on('unhandledRejection', (reason) => {
27
70
  console.error(chalk.red('\n✗ Unhandled error:'), reason?.message || reason);
@@ -45,7 +88,7 @@ const program = new Command();
45
88
 
46
89
  program
47
90
  .name('tas')
48
- .description(chalk.cyan('📦 TAS') + chalk.dim(' - Telegram as Storage | Free • Encrypted • Unlimited'))
91
+ .description(chalk.cyan('📦 TAS') + chalk.dim(' - Experimental encrypted storage over Telegram'))
49
92
  .version(VERSION)
50
93
  .hook('preAction', (thisCommand) => {
51
94
  // Show banner for main commands
@@ -58,7 +101,10 @@ program
58
101
  program
59
102
  .command('init')
60
103
  .description('Initialize TAS and connect to Telegram')
61
- .action(async () => {
104
+ .option('--token <token>', 'Telegram bot token (non-interactive / CI mode)')
105
+ .option('--chat <chatId>', 'Telegram chat ID (non-interactive / CI mode)')
106
+ .option('-p, --password <password>', 'Encryption password (non-interactive / CI mode, or TAS_PASSWORD env)')
107
+ .action(async (options) => {
62
108
  console.log(chalk.cyan('\n🚀 Initializing Telegram as Storage...\n'));
63
109
 
64
110
  // Ensure data directory exists
@@ -66,42 +112,67 @@ program
66
112
  fs.mkdirSync(DATA_DIR, { recursive: true });
67
113
  }
68
114
 
69
- // Get bot token
70
- console.log(chalk.yellow('📱 First, create a Telegram bot:'));
71
- console.log(chalk.dim(' 1. Open Telegram and message @BotFather'));
72
- console.log(chalk.dim(' 2. Send /newbot and follow the prompts'));
73
- console.log(chalk.dim(' 3. Copy the bot token\n'));
74
-
75
- const { token } = await inquirer.prompt([
76
- {
77
- type: 'password',
78
- name: 'token',
79
- message: 'Enter your Telegram bot token:',
80
- mask: '*',
81
- validate: (input) => input.includes(':') || 'Invalid token format (should contain :)'
115
+ const envPassword = process.env.TAS_PASSWORD;
116
+ let token = options.token;
117
+ let password = options.password || envPassword;
118
+ let presetChatId = options.chat;
119
+
120
+ const nonInteractive = Boolean(token && presetChatId && password);
121
+
122
+ if (!nonInteractive) {
123
+ // Get bot token
124
+ console.log(chalk.yellow('📱 First, create a Telegram bot:'));
125
+ console.log(chalk.dim(' 1. Open Telegram and message @BotFather'));
126
+ console.log(chalk.dim(' 2. Send /newbot and follow the prompts'));
127
+ console.log(chalk.dim(' 3. Copy the bot token\n'));
128
+ console.log(chalk.dim(' Tip: `tas init --token <token> --chat <id> --password <pw>` skips all prompts (CI/Docker).\n'));
129
+
130
+ if (!token) {
131
+ const answer = await inquirer.prompt([
132
+ {
133
+ type: 'password',
134
+ name: 'token',
135
+ message: 'Enter your Telegram bot token:',
136
+ mask: '*',
137
+ validate: (input) => input.includes(':') || 'Invalid token format (should contain :)'
138
+ }
139
+ ]);
140
+ token = answer.token;
82
141
  }
83
- ]);
84
142
 
85
- // Get encryption password
86
- const { password } = await inquirer.prompt([
87
- {
88
- type: 'password',
89
- name: 'password',
90
- message: 'Set your encryption password (used for all files):',
91
- mask: '*',
92
- validate: (input) => input.length >= 8 || 'Password must be at least 8 characters'
143
+ // Get encryption password
144
+ if (!password) {
145
+ const answer = await inquirer.prompt([
146
+ {
147
+ type: 'password',
148
+ name: 'password',
149
+ message: 'Set your encryption password (used for all files):',
150
+ mask: '*',
151
+ validate: (input) => input.length >= 8 || 'Password must be at least 8 characters'
152
+ }
153
+ ]);
154
+ password = answer.password;
155
+
156
+ const { confirmPassword } = await inquirer.prompt([
157
+ {
158
+ type: 'password',
159
+ name: 'confirmPassword',
160
+ message: 'Confirm password:',
161
+ mask: '*',
162
+ validate: (input) => input === password || 'Passwords do not match'
163
+ }
164
+ ]);
93
165
  }
94
- ]);
166
+ }
95
167
 
96
- const { confirmPassword } = await inquirer.prompt([
97
- {
98
- type: 'password',
99
- name: 'confirmPassword',
100
- message: 'Confirm password:',
101
- mask: '*',
102
- validate: (input) => input === password || 'Passwords do not match'
103
- }
104
- ]);
168
+ if (!token || !token.includes(':')) {
169
+ console.error(chalk.red('✗ Invalid bot token (should contain :)'));
170
+ process.exit(1);
171
+ }
172
+ if (!password || password.length < 8) {
173
+ console.error(chalk.red('✗ Password must be at least 8 characters'));
174
+ process.exit(1);
175
+ }
105
176
 
106
177
  // Initialize encryption
107
178
  const encryptor = new Encryptor(password);
@@ -114,27 +185,38 @@ program
114
185
  const botInfo = await client.initialize(token);
115
186
  spinner.succeed(`Connected as @${botInfo.username}`);
116
187
 
117
- // Wait for user to message the bot
118
- console.log(chalk.yellow(`\n📩 Now message your bot @${botInfo.username} on Telegram`));
119
- console.log(chalk.dim(' (Just send any message to link your account)\n'));
188
+ let userInfo;
189
+ if (presetChatId) {
190
+ // Non-interactive: trust the provided chat ID (CI/Docker)
191
+ client.setChatId(presetChatId);
192
+ userInfo = { chatId: presetChatId, username: undefined, firstName: 'ci-user' };
193
+ spinner.succeed(`Using chat ID ${presetChatId} (non-interactive)`);
194
+ } else {
195
+ // Wait for user to message the bot
196
+ console.log(chalk.yellow(`\n📩 Now message your bot @${botInfo.username} on Telegram`));
197
+ console.log(chalk.dim(' (Just send any message to link your account)\n'));
120
198
 
121
- spinner.start('Waiting for your message...');
122
- const userInfo = await client.waitForChatId(120000);
123
- spinner.succeed(`Linked to ${userInfo.firstName} (@${userInfo.username})`);
199
+ spinner.start('Waiting for your message...');
200
+ userInfo = await client.waitForChatId(120000);
201
+ spinner.succeed(`Linked to ${userInfo.firstName} (@${userInfo.username})`);
202
+ }
124
203
 
125
204
  // Save config (bot token encrypted with user's password)
126
- const configPath = path.join(DATA_DIR, 'config.json');
127
205
  const encryptedToken = encryptor.encrypt(Buffer.from(token, 'utf-8')).toString('base64');
128
- fs.writeFileSync(configPath, JSON.stringify({
129
- encryptedBotToken: encryptedToken,
130
- chatId: userInfo.chatId,
206
+ saveConfig(DATA_DIR, {
207
+ bots: [{
208
+ id: 'primary',
209
+ encryptedBotToken: encryptedToken,
210
+ chatId: userInfo.chatId,
211
+ username: botInfo.username,
212
+ enabled: true,
213
+ createdAt: new Date().toISOString()
214
+ }],
131
215
  passwordHash: encryptor.getPasswordHash(),
132
216
  username: userInfo.username,
133
217
  createdAt: new Date().toISOString(),
134
- configVersion: 2
135
- }, null, 2));
136
- // Restrict config file permissions — contains encrypted token and password hash
137
- try { fs.chmodSync(configPath, 0o600); } catch { /* ignore on Windows */ }
218
+ configVersion: 3
219
+ });
138
220
 
139
221
  // Initialize database
140
222
  spinner.start('Initializing local index...');
@@ -146,10 +228,12 @@ program
146
228
  await client.bot.sendMessage(userInfo.chatId,
147
229
  '📦 *TAS - Telegram as Storage*\n\n' +
148
230
  '✅ Setup complete! This chat will store your encrypted files.\n\n' +
231
+ '⚠️ Experimental: Telegram provides no TAS durability or account guarantee. Keep another backup.\n\n' +
149
232
  '_Do not delete messages in this chat._',
150
233
  { parse_mode: 'Markdown' }
151
234
  );
152
235
 
236
+ console.log(chalk.yellow('\n⚠ TAS is experimental, Telegram provides no durability/account guarantee, and current Bot Developer Terms may restrict cloud-storage use. Keep an independent backup.'));
153
237
  console.log(chalk.cyan('\n🎉 TAS is ready! Use `tas push <file>` to upload files.\n'));
154
238
 
155
239
  } catch (err) {
@@ -158,66 +242,332 @@ program
158
242
  }
159
243
  });
160
244
 
161
- // ============== PUSH COMMAND ==============
162
- program
163
- .command('push <file>')
164
- .description('Upload a file to Telegram storage')
165
- .option('-n, --name <name>', 'Custom name for the file')
166
- .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
167
- .action(async (file, options) => {
168
- const spinner = ora('Preparing...').start();
245
+ // ============== BOT POOL COMMANDS ==============
246
+ const botCmd = program
247
+ .command('bot')
248
+ .description('Manage the experimental multi-bot storage pool');
169
249
 
250
+ botCmd
251
+ .command('list')
252
+ .description('List configured bots without exposing their tokens')
253
+ .action(() => {
254
+ const config = requireConfig(DATA_DIR);
255
+ const bots = getBotEntries(config);
256
+ let db = null;
170
257
  try {
171
- // Check file exists
172
- if (!fs.existsSync(file)) {
173
- spinner.fail(`File not found: ${file}`);
174
- process.exit(1);
258
+ db = new FileIndex(path.join(DATA_DIR, 'index.db'));
259
+ db.init();
260
+ } catch { /* an empty vault may not have a database yet */ }
261
+
262
+ console.log(chalk.cyan(`\n🤖 Telegram Bot Pool (${bots.length})\n`));
263
+ for (const bot of bots) {
264
+ const chunks = db ? db.countChunksByBot(bot.id) : 0;
265
+ const pendingChunks = db ? db.countPendingChunksByBot(bot.id) : 0;
266
+ const state = bot.enabled === false ? chalk.yellow('disabled') : chalk.green('enabled');
267
+ const username = bot.username ? `@${String(bot.username).replace(/^@/, '')}` : 'username unknown';
268
+ console.log(` ${chalk.blue(bot.id.padEnd(16))} ${state} ${username} chat=${bot.chatId} chunks=${chunks} pending=${pendingChunks}`);
269
+ }
270
+ db?.close();
271
+ console.log(chalk.dim('\nDisabled bots remain configured so TAS can read their existing chunks.\n'));
272
+ });
273
+
274
+ botCmd
275
+ .command('add')
276
+ .description('Add a bot to the experimental storage pool')
277
+ .option('--token <token>', 'Telegram bot token')
278
+ .option('--chat <chatId>', 'Telegram storage chat ID')
279
+ .option('--name <id>', 'Stable bot ID (letters, numbers, _ and -)')
280
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
281
+ .option('--accept-risk', 'Acknowledge the multi-bot warning in non-interactive use')
282
+ .action(async (options) => {
283
+ const rawConfig = requireConfig(DATA_DIR);
284
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
285
+
286
+ console.log(chalk.yellow(`\n⚠ ${MULTI_BOT_WARNING}\n`));
287
+ if (!options.acceptRisk) {
288
+ if (!process.stdin.isTTY) {
289
+ throw new Error('Non-interactive bot add requires --accept-risk');
175
290
  }
291
+ const { accepted } = await inquirer.prompt([{
292
+ type: 'confirm',
293
+ name: 'accepted',
294
+ message: 'I understand and still want to add another bot',
295
+ default: false
296
+ }]);
297
+ if (!accepted) return;
298
+ }
176
299
 
177
- const rawConfig = requireConfig(DATA_DIR);
178
- spinner.stop();
300
+ let token = options.token;
301
+ if (!token) {
302
+ ({ token } = await inquirer.prompt([{
303
+ type: 'password',
304
+ name: 'token',
305
+ message: 'Enter the additional Telegram bot token:',
306
+ mask: '*',
307
+ validate: input => input.includes(':') || 'Invalid token format (should contain :)'
308
+ }]));
309
+ }
310
+ if (!token || !token.includes(':')) throw new Error('Invalid bot token (should contain :)');
311
+ if (resolveConfig(rawConfig, password).bots.some(bot => bot.botToken === token)) {
312
+ throw new Error('That bot token is already configured');
313
+ }
179
314
 
180
- // Get and verify password
181
- const password = await getAndVerifyPassword(options.password, DATA_DIR);
182
- const config = resolveConfig(rawConfig, password);
315
+ const client = new TelegramClient(DATA_DIR);
316
+ const info = await client.initialize(token);
317
+ let chatId = options.chat;
318
+ if (!chatId) {
319
+ console.log(chalk.yellow(`\n📩 Send any message to @${info.username} to select its storage chat.`));
320
+ ({ chatId } = await client.waitForChatId(120000));
321
+ }
322
+ client.setChatId(chatId);
323
+
324
+ const config = migrateConfigToV3(rawConfig, password);
325
+ const baseId = normalizeBotId(options.name || info.username);
326
+ let id = baseId;
327
+ let suffix = 2;
328
+ while (config.bots.some(bot => bot.id === id)) id = `${baseId.slice(0, 28)}-${suffix++}`;
329
+
330
+ config.bots.push({
331
+ id,
332
+ encryptedBotToken: encryptBotToken(token, password),
333
+ chatId,
334
+ username: info.username,
335
+ enabled: true,
336
+ createdAt: new Date().toISOString()
337
+ });
338
+ config.multiBotRiskAcceptedAt = new Date().toISOString();
339
+ saveConfig(DATA_DIR, config);
340
+
341
+ await client.bot.sendMessage(chatId,
342
+ '📦 *TAS storage bot added*\n\n' +
343
+ '⚠️ Experimental. This does not guarantee quota, durability, ban avoidance, or Terms compliance. Keep another backup.\n\n' +
344
+ '_Do not delete TAS chunk messages in this chat._',
345
+ { parse_mode: 'Markdown' }
346
+ );
347
+ console.log(chalk.green(`\n✓ Added @${info.username} as bot ID "${id}"\n`));
348
+ });
183
349
 
184
- spinner.start('Processing file...');
350
+ async function setBotEnabled(id, enabled, options) {
351
+ const rawConfig = requireConfig(DATA_DIR);
352
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
353
+ const config = migrateConfigToV3(rawConfig, password);
354
+ const bot = config.bots.find(entry => entry.id === id);
355
+ if (!bot) throw new Error(`Unknown bot ID: ${id}`);
356
+ if (!enabled && config.bots.filter(entry => entry.enabled !== false).length <= 1) {
357
+ throw new Error('Cannot disable the last enabled bot');
358
+ }
359
+ bot.enabled = enabled;
360
+ saveConfig(DATA_DIR, config);
361
+ console.log(chalk.green(`✓ ${enabled ? 'Enabled' : 'Disabled'} bot "${id}"`));
362
+ if (!enabled) console.log(chalk.dim(' Existing chunks remain readable through this bot.'));
363
+ }
185
364
 
186
- // Import progress bar
187
- const { ProgressBar } = await import('./utils/progress.js');
188
- let progressBar = null;
365
+ botCmd
366
+ .command('enable <id>')
367
+ .description('Enable a configured bot for new uploads')
368
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
369
+ .action((id, options) => setBotEnabled(id, true, options));
370
+
371
+ botCmd
372
+ .command('disable <id>')
373
+ .description('Stop routing new chunks to a bot but keep old chunks readable')
374
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
375
+ .action((id, options) => setBotEnabled(id, false, options));
376
+
377
+ botCmd
378
+ .command('remove <id>')
379
+ .description('Remove an unused bot (refuses while indexed chunks depend on it)')
380
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
381
+ .action(async (id, options) => {
382
+ const rawConfig = requireConfig(DATA_DIR);
383
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
384
+ const config = migrateConfigToV3(rawConfig, password);
385
+ const index = config.bots.findIndex(bot => bot.id === id);
386
+ if (index < 0) throw new Error(`Unknown bot ID: ${id}`);
387
+ if (config.bots.length === 1) throw new Error('Cannot remove the only configured bot');
388
+ if (rawConfig.remoteManifest?.botId === id) {
389
+ throw new Error(`Cannot remove "${id}": the current remote recovery manifest depends on it. Run \`tas index backup\` after enabling another bot first.`);
390
+ }
189
391
 
190
- // Process and upload
191
- const result = await processFile(file, {
192
- password,
193
- dataDir: DATA_DIR,
194
- customName: options.name,
195
- config,
196
- onProgress: (msg) => {
197
- if (!progressBar) spinner.text = msg;
198
- },
199
- onByteProgress: ({ uploaded, total }) => {
200
- if (!progressBar) {
201
- spinner.stop();
202
- progressBar = new ProgressBar({ label: 'Uploading', total });
203
- }
204
- progressBar.update(uploaded);
392
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
393
+ db.init();
394
+ const chunkCount = db.countChunksByBot(id);
395
+ const pendingChunkCount = db.countPendingChunksByBot(id);
396
+ db.close();
397
+ if (chunkCount > 0 || pendingChunkCount > 0) {
398
+ throw new Error(
399
+ `Cannot remove "${id}": ${chunkCount} indexed and ${pendingChunkCount} pending chunk(s) still depend on it. Disable it instead.`
400
+ );
401
+ }
402
+
403
+ config.bots.splice(index, 1);
404
+ if (!config.bots.some(bot => bot.enabled !== false)) config.bots[0].enabled = true;
405
+ saveConfig(DATA_DIR, config);
406
+ console.log(chalk.green(`✓ Removed unused bot "${id}"`));
407
+ });
408
+
409
+ // ============== REMOTE INDEX RECOVERY ==============
410
+ const indexCmd = program
411
+ .command('index')
412
+ .description('Back up or rebuild the local SQLite index');
413
+
414
+ indexCmd
415
+ .command('backup')
416
+ .description('Publish a fresh encrypted index manifest to Telegram')
417
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
418
+ .action(async (options) => {
419
+ const rawConfig = requireConfig(DATA_DIR);
420
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
421
+ const config = resolveConfig(rawConfig, password);
422
+ const pool = new TelegramPool(DATA_DIR, config.bots);
423
+ const manifest = await backupRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool: pool });
424
+ console.log(chalk.green(`✓ Encrypted recovery manifest published (${manifest.files} files, ${manifest.chunks} chunks)`));
425
+ });
426
+
427
+ indexCmd
428
+ .command('rebuild')
429
+ .description('Rebuild index.db from the encrypted remote manifest')
430
+ .option('-p, --password <password>', 'Vault password (or TAS_PASSWORD)')
431
+ .option('--force', 'Replace the current index without an interactive confirmation')
432
+ .action(async (options) => {
433
+ const rawConfig = requireConfig(DATA_DIR);
434
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
435
+ const config = resolveConfig(rawConfig, password);
436
+ const pool = new TelegramPool(DATA_DIR, config.bots);
437
+
438
+ const spinner = ora('Downloading and authenticating remote manifest...').start();
439
+ const manifest = await downloadRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool: pool });
440
+ spinner.succeed(`Authenticated manifest: ${manifest.files.length} files, ${manifest.chunks.length} chunks`);
441
+
442
+ const dbPath = path.join(DATA_DIR, 'index.db');
443
+ let currentCount = 0;
444
+ if (fs.existsSync(dbPath)) {
445
+ const current = new FileIndex(dbPath);
446
+ current.init();
447
+ currentCount = current.getStats().file_count;
448
+ current.close();
449
+ }
450
+ if (currentCount > 0 && !options.force) {
451
+ if (!process.stdin.isTTY) throw new Error('Refusing to replace a non-empty index without --force');
452
+ const { confirmed } = await inquirer.prompt([{
453
+ type: 'confirm',
454
+ name: 'confirmed',
455
+ message: `Replace the current ${currentCount}-file index with the remote recovery point?`,
456
+ default: false
457
+ }]);
458
+ if (!confirmed) return;
459
+ }
460
+
461
+ let backupPath = null;
462
+ if (fs.existsSync(dbPath)) {
463
+ backupPath = `${dbPath}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
464
+ const current = new FileIndex(dbPath);
465
+ current.init();
466
+ current.db.pragma('wal_checkpoint(TRUNCATE)');
467
+ current.close();
468
+ fs.copyFileSync(dbPath, backupPath);
469
+ }
470
+
471
+ const rebuilt = new FileIndex(dbPath);
472
+ rebuilt.init();
473
+ rebuilt.importManifest(manifest);
474
+ const integrity = rebuilt.db.pragma('integrity_check', { simple: true });
475
+ rebuilt.close();
476
+ if (integrity !== 'ok') throw new Error(`SQLite integrity check failed after rebuild: ${integrity}`);
477
+
478
+ console.log(chalk.green(`✓ Rebuilt index with ${manifest.files.length} files`));
479
+ if (backupPath) console.log(chalk.dim(` Previous index backup: ${backupPath}`));
480
+ });
481
+
482
+ // ============== PUSH COMMAND ==============
483
+ program
484
+ .command('push <files...>')
485
+ .description('Upload one or more files to Telegram storage')
486
+ .option('-n, --name <name>', 'Custom name for the file (single-file uploads only)')
487
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
488
+ .action(async (files, options) => {
489
+ if (files.length > 1 && options.name) {
490
+ console.error(chalk.red('✗ --name can only be used with a single file'));
491
+ process.exit(1);
492
+ }
493
+
494
+ const rawConfig = requireConfig(DATA_DIR);
495
+
496
+ // Get and verify password once for the whole batch
497
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
498
+ const config = resolveConfig(rawConfig, password);
499
+ warnIfMultiBot(config);
500
+ const telegramPool = new TelegramPool(DATA_DIR, config.bots);
501
+ await telegramPool.initialize({ includeDisabled: false });
502
+
503
+ const { ProgressBar } = await import('./utils/progress.js');
504
+ let succeeded = 0;
505
+ let failed = 0;
506
+
507
+ for (const file of files) {
508
+ const spinner = ora(`Preparing ${file}...`).start();
509
+ try {
510
+ if (!fs.existsSync(file)) {
511
+ spinner.fail(`File not found: ${file}`);
512
+ failed++;
513
+ continue;
514
+ }
515
+ if (!fs.statSync(file).isFile()) {
516
+ spinner.fail(`Not a file, skipping: ${file}`);
517
+ failed++;
518
+ continue;
205
519
  }
206
- });
207
520
 
208
- if (progressBar) {
209
- progressBar.complete(`Uploaded: ${result.filename}`);
210
- } else {
211
- spinner.succeed(`Uploaded: ${chalk.green(result.filename)}`);
521
+ spinner.text = 'Processing file...';
522
+ let progressBar = null;
523
+
524
+ const result = await processFile(file, {
525
+ password,
526
+ dataDir: DATA_DIR,
527
+ customName: options.name,
528
+ config,
529
+ telegramPool,
530
+ updateManifest: false,
531
+ onProgress: (msg) => {
532
+ if (!progressBar) spinner.text = `${file}: ${msg}`;
533
+ },
534
+ onByteProgress: ({ uploaded, total }) => {
535
+ if (!progressBar) {
536
+ spinner.stop();
537
+ progressBar = new ProgressBar({ label: `Uploading ${file}`, total });
538
+ }
539
+ progressBar.update(uploaded);
540
+ }
541
+ });
542
+
543
+ if (progressBar) {
544
+ progressBar.complete(`Uploaded: ${result.filename}`);
545
+ } else {
546
+ spinner.succeed(`Uploaded: ${chalk.green(result.filename)}`);
547
+ }
548
+ console.log(chalk.dim(` Hash: ${result.hash}`));
549
+ console.log(chalk.dim(` Size: ${formatBytes(result.originalSize)} → ${formatBytes(result.storedSize)}`));
550
+ console.log(chalk.dim(` Chunks: ${result.chunks}`));
551
+ succeeded++;
552
+ } catch (err) {
553
+ spinner.fail(`Upload failed for ${file}: ${err.message}`);
554
+ failed++;
212
555
  }
213
- console.log(chalk.dim(` Hash: ${result.hash}`));
214
- console.log(chalk.dim(` Size: ${formatBytes(result.originalSize)} → ${formatBytes(result.storedSize)}`));
215
- console.log(chalk.dim(` Chunks: ${result.chunks}`));
556
+ }
216
557
 
217
- } catch (err) {
218
- spinner.fail(`Upload failed: ${err.message}`);
219
- process.exit(1);
558
+ if (files.length > 1) {
559
+ console.log(chalk.cyan(`\nDone: ${succeeded} uploaded, ${failed} failed\n`));
220
560
  }
561
+ if (succeeded > 0) {
562
+ try {
563
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool });
564
+ console.log(chalk.dim('Encrypted remote recovery manifest updated.'));
565
+ } catch (error) {
566
+ console.log(chalk.yellow(`⚠ Files uploaded, but recovery manifest update failed: ${error.message}`));
567
+ process.exitCode = 1;
568
+ }
569
+ }
570
+ if (failed > 0) process.exitCode = 1;
221
571
  });
222
572
 
223
573
  // ============== PULL COMMAND ==============
@@ -247,6 +597,7 @@ program
247
597
  // Get and verify password
248
598
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
249
599
  const config = resolveConfig(rawConfig, password);
600
+ warnIfMultiBot(config);
250
601
 
251
602
  spinner.start('Downloading...');
252
603
 
@@ -344,7 +695,7 @@ program
344
695
  .alias('rm')
345
696
  .description('Remove a file from the index (optionally from Telegram too)')
346
697
  .option('--hard', 'Also delete from Telegram')
347
- .option('-p, --password <password>', 'Encryption password (required for --hard)')
698
+ .option('-p, --password <password>', 'Encryption password (or TAS_PASSWORD)')
348
699
  .action(async (identifier, options) => {
349
700
  try {
350
701
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -356,33 +707,43 @@ program
356
707
  process.exit(1);
357
708
  }
358
709
 
710
+ if (!options.hard) {
711
+ console.log(chalk.yellow(' Note: default delete removes the local index entry only — the encrypted copy stays on Telegram. Use --hard to also delete the Telegram message.'));
712
+ } else {
713
+ console.log(chalk.yellow(' Note: --hard deletes the Telegram message, but Telegram may retain the underlying file blob (file_id can outlive the message). Treat as best-effort, not cryptographic erasure.'));
714
+ }
715
+
359
716
  const { confirm } = await inquirer.prompt([
360
717
  {
361
718
  type: 'confirm',
362
719
  name: 'confirm',
363
- message: `Delete "${fileRecord.filename}" from index${options.hard ? ' and Telegram' : ''}?`,
720
+ message: `Delete "${fileRecord.filename}" from index${options.hard ? ' and Telegram message' : ' (Telegram copy retained)'}?`,
364
721
  default: false
365
722
  }
366
723
  ]);
367
724
 
368
725
  if (confirm) {
369
- // If hard delete, also remove from Telegram
370
- if (options.hard) {
371
- const rawConfig = requireConfig(DATA_DIR);
372
- const password = await getAndVerifyPassword(options.password, DATA_DIR);
373
- const config = resolveConfig(rawConfig, password);
726
+ const rawConfig = requireConfig(DATA_DIR);
727
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
728
+ const config = resolveConfig(rawConfig, password);
729
+ warnIfMultiBot(config);
730
+ const client = new TelegramPool(DATA_DIR, config.bots);
731
+ const chunks = db.getChunks(fileRecord.id);
732
+ const before = db.exportManifest({ includeShares: true });
733
+ db.delete(fileRecord.id);
374
734
 
375
- const client = new TelegramClient(DATA_DIR);
376
- await client.initialize(config.botToken);
377
- client.setChatId(config.chatId);
735
+ try {
736
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool: client });
737
+ } catch (error) {
738
+ db.importManifest(before);
739
+ throw new Error(`Delete rolled back because the recovery manifest could not be updated: ${error.message}`);
740
+ }
378
741
 
379
- const chunks = db.getChunks(fileRecord.id);
742
+ if (options.hard) {
380
743
  for (const chunk of chunks) {
381
- await client.deleteMessage(chunk.message_id);
744
+ await client.deleteMessage(chunk.message_id, chunk.bot_id || null);
382
745
  }
383
746
  }
384
-
385
- db.delete(fileRecord.id);
386
747
  console.log(chalk.green(`✓ Removed "${fileRecord.filename}"`));
387
748
  }
388
749
 
@@ -417,12 +778,16 @@ program
417
778
  const totalSize = files.reduce((acc, f) => acc + f.original_size, 0);
418
779
  const storedSize = files.reduce((acc, f) => acc + f.stored_size, 0);
419
780
  const savings = totalSize > 0 ? Math.round((1 - storedSize / totalSize) * 100) : 0;
781
+ const bots = getBotEntries(config);
420
782
 
421
783
  if (options.json) {
422
784
  console.log(JSON.stringify({
423
785
  initialized: true,
424
786
  createdAt: config.createdAt,
425
787
  username: config.username || 'unknown',
788
+ botCount: bots.length,
789
+ enabledBots: bots.filter(bot => bot.enabled !== false).length,
790
+ remoteManifest: config.remoteManifest || null,
426
791
  fileCount: files.length,
427
792
  totalSize,
428
793
  storedSize,
@@ -436,6 +801,8 @@ program
436
801
  console.log(chalk.cyan('\n📊 TAS Status\n'));
437
802
  console.log(` Initialized: ${chalk.white(new Date(config.createdAt).toLocaleDateString())}`);
438
803
  console.log(` Telegram user: ${chalk.white('@' + (config.username || 'unknown'))}`);
804
+ console.log(` Bot pool: ${chalk.white(`${bots.filter(bot => bot.enabled !== false).length}/${bots.length} enabled`)}`);
805
+ console.log(` Recovery manifest: ${config.remoteManifest ? chalk.green(config.remoteManifest.createdAt) : chalk.yellow('not published yet')}`);
439
806
  console.log(` Data dir: ${chalk.white(DATA_DIR)}`);
440
807
  console.log(` Files stored: ${chalk.white(files.length)}`);
441
808
  console.log(` Total size: ${chalk.white(formatBytes(totalSize))}`);
@@ -447,7 +814,7 @@ program
447
814
  // ============== MOUNT COMMAND ==============
448
815
  program
449
816
  .command('mount <mountpoint>')
450
- .description('🔥 Mount Telegram storage as a local folder (FUSE)')
817
+ .description('🔥 Mount Telegram storage as a local folder (Linux FUSE or current macFUSE)')
451
818
  .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
452
819
  .action(async (mountpoint, options) => {
453
820
  console.log(chalk.cyan('\n🗂️ Mounting Telegram as filesystem...\n'));
@@ -505,7 +872,7 @@ program
505
872
  console.log(chalk.dim('\nNote: FUSE requires libfuse to be installed:'));
506
873
  console.log(chalk.dim(' Ubuntu/Debian: sudo apt install fuse libfuse-dev'));
507
874
  console.log(chalk.dim(' Fedora: sudo dnf install fuse fuse-devel'));
508
- console.log(chalk.dim(' macOS: brew install macfuse\n'));
875
+ console.log(chalk.dim(' macOS: install current macFUSE and Xcode Command Line Tools, then reinstall TAS\n'));
509
876
  process.exit(1);
510
877
  }
511
878
  });
@@ -521,18 +888,19 @@ program
521
888
  const spinner = ora('Unmounting...').start();
522
889
 
523
890
  try {
524
- const { execSync } = await import('child_process');
891
+ const { execFileSync } = await import('child_process');
525
892
 
526
- // Use fusermount on Linux, umount on macOS
893
+ // Match fuse-native's platform unmount mechanism without invoking a shell.
527
894
  const isMac = process.platform === 'darwin';
528
- const cmd = isMac ? `umount "${absMount}"` : `fusermount -u "${absMount}"`;
529
-
530
- execSync(cmd, { stdio: 'pipe' });
895
+ const executable = isMac ? 'diskutil' : 'fusermount';
896
+ const args = isMac ? ['unmount', 'force', absMount] : ['-u', absMount];
897
+ execFileSync(executable, args, { stdio: 'pipe' });
531
898
 
532
899
  spinner.succeed(`Unmounted ${chalk.green(absMount)}`);
533
900
  } catch (err) {
534
901
  spinner.fail(`Unmount failed: ${err.message}`);
535
- console.log(chalk.dim('\nTry: fusermount -u ' + absMount));
902
+ const retry = process.platform === 'darwin' ? `diskutil unmount force ${absMount}` : `fusermount -u ${absMount}`;
903
+ console.log(chalk.dim('\nTry: ' + retry));
536
904
  process.exit(1);
537
905
  }
538
906
  });
@@ -545,10 +913,12 @@ const tagCmd = program
545
913
  tagCmd
546
914
  .command('add <file> <tags...>')
547
915
  .description('Add tags to a file')
548
- .action(async (file, tags) => {
916
+ .option('-p, --password <password>', 'Encryption password (or TAS_PASSWORD)')
917
+ .action(async (file, tags, options) => {
549
918
  try {
550
919
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
551
920
  db.init();
921
+ const before = db.exportManifest({ includeShares: true });
552
922
 
553
923
  const fileRecord = db.findByHash(file) || db.findByName(file);
554
924
  if (!fileRecord) {
@@ -560,6 +930,16 @@ tagCmd
560
930
  db.addTag(fileRecord.id, tag);
561
931
  }
562
932
 
933
+ const rawConfig = requireConfig(DATA_DIR);
934
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
935
+ const config = resolveConfig(rawConfig, password);
936
+ try {
937
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config });
938
+ } catch (error) {
939
+ db.importManifest(before);
940
+ throw new Error(`Tag update rolled back because the recovery manifest failed: ${error.message}`);
941
+ }
942
+
563
943
  const allTags = db.getFileTags(fileRecord.id);
564
944
  console.log(chalk.green(`✓ Tags updated for "${fileRecord.filename}"`));
565
945
  console.log(chalk.dim(` Tags: ${allTags.join(', ')}`));
@@ -574,10 +954,12 @@ tagCmd
574
954
  tagCmd
575
955
  .command('remove <file> <tags...>')
576
956
  .description('Remove tags from a file')
577
- .action(async (file, tags) => {
957
+ .option('-p, --password <password>', 'Encryption password (or TAS_PASSWORD)')
958
+ .action(async (file, tags, options) => {
578
959
  try {
579
960
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
580
961
  db.init();
962
+ const before = db.exportManifest({ includeShares: true });
581
963
 
582
964
  const fileRecord = db.findByHash(file) || db.findByName(file);
583
965
  if (!fileRecord) {
@@ -589,6 +971,16 @@ tagCmd
589
971
  db.removeTag(fileRecord.id, tag);
590
972
  }
591
973
 
974
+ const rawConfig = requireConfig(DATA_DIR);
975
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
976
+ const config = resolveConfig(rawConfig, password);
977
+ try {
978
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config });
979
+ } catch (error) {
980
+ db.importManifest(before);
981
+ throw new Error(`Tag update rolled back because the recovery manifest failed: ${error.message}`);
982
+ }
983
+
592
984
  const allTags = db.getFileTags(fileRecord.id);
593
985
  console.log(chalk.green(`✓ Tags updated for "${fileRecord.filename}"`));
594
986
  console.log(chalk.dim(` Tags: ${allTags.length > 0 ? allTags.join(', ') : '(none)'}`));
@@ -790,6 +1182,10 @@ syncCmd
790
1182
  console.log(chalk.red(` ✗ Failed: ${file} - ${error}`));
791
1183
  });
792
1184
 
1185
+ syncEngine.on('manifest-error', ({ error }) => {
1186
+ console.log(chalk.yellow(` ⚠ Files synced, but remote recovery manifest failed: ${error}`));
1187
+ });
1188
+
793
1189
  syncEngine.on('watch-start', ({ folder }) => {
794
1190
  console.log(chalk.cyan(`👁️ Watching: ${folder}`));
795
1191
  });
@@ -830,6 +1226,7 @@ syncCmd
830
1226
  const rawConfig = requireConfig(DATA_DIR);
831
1227
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
832
1228
  const config = resolveConfig(rawConfig, password);
1229
+ warnIfMultiBot(config);
833
1230
 
834
1231
  const spinner = ora('Loading...').start();
835
1232
 
@@ -852,52 +1249,63 @@ syncCmd
852
1249
 
853
1250
  spinner.succeed(`Found ${files.length} files in Telegram`);
854
1251
 
855
- // Download each file that matches a sync folder
1252
+ // Download each file that matches a sync folder.
1253
+ // Remote files are stored under their sync relative path
1254
+ // (customName at upload time), so join directly. A local file
1255
+ // is skipped only when its content hash matches the index —
1256
+ // existence alone is not enough (edited files must re-pull).
1257
+ // Each file goes to the first folder only to avoid duplicates
1258
+ // when several folders are registered.
856
1259
  let downloaded = 0;
857
1260
  let skipped = 0;
1261
+ const { hashFile } = await import('./crypto/encryption.js');
1262
+ const telegramPool = new TelegramPool(DATA_DIR, config.bots);
858
1263
 
859
1264
  for (const file of files) {
860
- // Check if file belongs to any sync folder (by name prefix)
861
- for (const folder of folders) {
862
- const folderName = path.basename(folder.local_path);
863
- const targetPath = path.join(folder.local_path, file.filename);
1265
+ const folder = folders[0];
1266
+ const targetPath = path.join(folder.local_path, file.filename);
864
1267
 
865
- // Check if file already exists locally with same hash
866
- if (fs.existsSync(targetPath)) {
867
- skipped++;
868
- continue;
869
- }
870
-
871
- // Ensure directory exists
872
- const targetDir = path.dirname(targetPath);
873
- if (!fs.existsSync(targetDir)) {
874
- fs.mkdirSync(targetDir, { recursive: true });
1268
+ // Skip only when local content already matches the index
1269
+ if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) {
1270
+ try {
1271
+ const localHash = await hashFile(targetPath);
1272
+ if (localHash === file.hash) {
1273
+ skipped++;
1274
+ continue;
1275
+ }
1276
+ console.log(chalk.yellow(` ↻ Updating modified file: ${file.filename}`));
1277
+ } catch {
1278
+ // Hash failed — fall through and re-download
875
1279
  }
1280
+ }
876
1281
 
877
- console.log(chalk.dim(` ↓ Downloading: ${file.filename}`));
1282
+ // Ensure directory exists
1283
+ const targetDir = path.dirname(targetPath);
1284
+ if (!fs.existsSync(targetDir)) {
1285
+ fs.mkdirSync(targetDir, { recursive: true });
1286
+ }
878
1287
 
879
- try {
880
- await retrieveFile(file, {
881
- password,
882
- dataDir: DATA_DIR,
883
- outputPath: targetPath,
884
- config,
885
- onProgress: () => { }
886
- });
1288
+ console.log(chalk.dim(` ↓ Downloading: ${file.filename}`));
887
1289
 
888
- // Update sync state
889
- const { hashFile } = await import('./crypto/encryption.js');
890
- const hash = await hashFile(targetPath);
891
- const stats = fs.statSync(targetPath);
892
- db.updateSyncState(folder.id, file.filename, hash, stats.mtimeMs);
1290
+ try {
1291
+ await retrieveFile(file, {
1292
+ password,
1293
+ dataDir: DATA_DIR,
1294
+ outputPath: targetPath,
1295
+ config,
1296
+ telegramPool,
1297
+ onProgress: () => { }
1298
+ });
893
1299
 
894
- console.log(chalk.green(` ✓ Downloaded: ${file.filename}`));
895
- downloaded++;
896
- } catch (err) {
897
- console.log(chalk.red(` ✗ Failed: ${file.filename} - ${err.message}`));
898
- }
1300
+ // Update sync state
1301
+ const hash = await hashFile(targetPath);
1302
+ const stats = fs.statSync(targetPath);
1303
+ db.updateSyncState(folder.id, file.filename, hash, stats.mtimeMs);
899
1304
 
900
- break; // Only download to first matching folder
1305
+ console.log(chalk.green(` ✓ Downloaded: ${file.filename}`));
1306
+ downloaded++;
1307
+ } catch (err) {
1308
+ console.log(chalk.red(` ✗ Failed: ${file.filename} - ${err.message}`));
901
1309
  }
902
1310
  }
903
1311
 
@@ -913,14 +1321,16 @@ syncCmd
913
1321
  // ============== VERIFY COMMAND ==============
914
1322
  program
915
1323
  .command('verify')
916
- .description('Verify file integrity and check for missing Telegram messages')
1324
+ .description('Check Telegram references; use --deep to download, decrypt, and hash every file')
917
1325
  .option('-p, --password <password>', 'Encryption password')
1326
+ .option('--deep', 'Download, authenticate, decompress, and SHA-256 verify every file')
918
1327
  .action(async (options) => {
919
1328
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
920
1329
 
921
1330
  const rawConfig = requireConfig(DATA_DIR);
922
1331
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
923
1332
  const config = resolveConfig(rawConfig, password);
1333
+ warnIfMultiBot(config);
924
1334
 
925
1335
  const spinner = ora('Checking files...').start();
926
1336
 
@@ -936,15 +1346,14 @@ program
936
1346
 
937
1347
  spinner.text = 'Connecting to Telegram...';
938
1348
 
939
- const client = new TelegramClient(DATA_DIR);
940
- await client.initialize(config.botToken);
941
- client.setChatId(config.chatId);
1349
+ const client = new TelegramPool(DATA_DIR, config.bots);
942
1350
 
943
1351
  spinner.succeed(`Checking ${files.length} files...`);
944
1352
 
945
1353
  let valid = 0;
946
1354
  let missing = 0;
947
1355
  let errors = [];
1356
+ const verifyDir = options.deep ? fs.mkdtempSync(path.join(os.tmpdir(), 'tas-verify-')) : null;
948
1357
 
949
1358
  for (const file of files) {
950
1359
  const chunks = db.getChunks(file.id);
@@ -960,7 +1369,7 @@ program
960
1369
  }
961
1370
 
962
1371
  // Check if file is accessible (will throw if deleted)
963
- await client.bot.getFile(chunk.file_telegram_id);
1372
+ await client.getFile(chunk.file_telegram_id, chunk.bot_id || null);
964
1373
  } catch (err) {
965
1374
  fileValid = false;
966
1375
  errors.push({
@@ -972,6 +1381,23 @@ program
972
1381
  }
973
1382
  }
974
1383
 
1384
+ if (fileValid && options.deep) {
1385
+ try {
1386
+ const verifyPath = path.join(verifyDir, String(file.id));
1387
+ await retrieveFile(file, {
1388
+ password,
1389
+ dataDir: DATA_DIR,
1390
+ outputPath: verifyPath,
1391
+ config,
1392
+ telegramPool: client
1393
+ });
1394
+ try { fs.unlinkSync(verifyPath); } catch { }
1395
+ } catch (error) {
1396
+ fileValid = false;
1397
+ errors.push({ file: file.filename, error: `Deep verification failed: ${error.message}` });
1398
+ }
1399
+ }
1400
+
975
1401
  if (fileValid) {
976
1402
  console.log(` ${chalk.green('✓')} ${file.filename}`);
977
1403
  valid++;
@@ -982,6 +1408,9 @@ program
982
1408
  }
983
1409
 
984
1410
  console.log();
1411
+ if (verifyDir) {
1412
+ try { fs.rmSync(verifyDir, { recursive: true, force: true }); } catch { }
1413
+ }
985
1414
  console.log(chalk.cyan('📊 Verification Results'));
986
1415
  console.log(` Valid: ${chalk.green(valid)}`);
987
1416
  console.log(` Missing: ${chalk.red(missing)}`);
@@ -1061,13 +1490,48 @@ program
1061
1490
  db.init();
1062
1491
 
1063
1492
  const pending = db.getPendingUploads();
1493
+ // Leftovers from pre-2.5 interrupted uploads (before processFile
1494
+ // cleaned up partial rows atomically). Offer to clear them so a
1495
+ // retry doesn't hit a phantom "duplicate hash".
1496
+ let orphans = [];
1497
+ try { orphans = db.getIncompleteUploads(); } catch { orphans = []; }
1064
1498
 
1065
- if (pending.length === 0) {
1499
+ if (pending.length === 0 && orphans.length === 0) {
1066
1500
  console.log(chalk.yellow('\n📭 No interrupted uploads found.\n'));
1067
1501
  db.close();
1068
1502
  return;
1069
1503
  }
1070
1504
 
1505
+ if (orphans.length > 0) {
1506
+ console.log(chalk.yellow(`\n⚠ ${orphans.length} incomplete file record(s) from interrupted uploads (pre-2.5):\n`));
1507
+ for (const o of orphans) {
1508
+ console.log(` ${chalk.blue('●')} ${o.filename} ${chalk.dim(`(${o.actual_chunks}/${o.chunks} chunks)`)}`);
1509
+ }
1510
+ console.log(chalk.dim('\n Current versions clean up partial uploads automatically.'));
1511
+ console.log(chalk.dim(' Clear these leftovers, then re-run `tas push <file>` to retry.\n'));
1512
+
1513
+ const { clearOrphans } = await inquirer.prompt([
1514
+ {
1515
+ type: 'confirm',
1516
+ name: 'clearOrphans',
1517
+ message: 'Delete incomplete file records now?',
1518
+ default: true
1519
+ }
1520
+ ]);
1521
+ if (clearOrphans) {
1522
+ for (const o of orphans) {
1523
+ try { db.deleteFileCascade(o.id); } catch { }
1524
+ }
1525
+ console.log(chalk.green('✓ Cleared incomplete uploads — retry with `tas push <file>`'));
1526
+ }
1527
+ orphans = [];
1528
+ }
1529
+
1530
+ if (pending.length === 0) {
1531
+ db.close();
1532
+ return;
1533
+ }
1534
+
1071
1535
  console.log(chalk.cyan(`\n🔄 Pending Uploads (${pending.length})\n`));
1072
1536
 
1073
1537
  for (const upload of pending) {
@@ -1099,10 +1563,17 @@ program
1099
1563
  }
1100
1564
 
1101
1565
  if (action === 'clear') {
1566
+ const rawConfig = requireConfig(DATA_DIR);
1567
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1568
+ const config = resolveConfig(rawConfig, password);
1569
+ const client = new TelegramPool(DATA_DIR, config.bots);
1102
1570
  for (const upload of pending) {
1103
1571
  // Clean up temp files
1104
1572
  const chunks = db.getPendingChunks(upload.id);
1105
1573
  for (const chunk of chunks) {
1574
+ if (chunk.uploaded && chunk.message_id) {
1575
+ try { await client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch { }
1576
+ }
1106
1577
  try { fs.unlinkSync(chunk.chunk_path); } catch (e) { }
1107
1578
  }
1108
1579
  if (upload.temp_dir) {
@@ -1126,12 +1597,13 @@ program
1126
1597
  // Get and verify password
1127
1598
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1128
1599
  const config = resolveConfig(rawConfig, password);
1600
+ warnIfMultiBot(config);
1129
1601
 
1130
1602
  // Connect to Telegram
1131
- const { TelegramClient } = await import('./telegram/client.js');
1132
- const client = new TelegramClient(DATA_DIR);
1133
- await client.initialize(config.botToken);
1134
- client.setChatId(config.chatId);
1603
+ const client = new TelegramPool(DATA_DIR, config.bots);
1604
+ await client.initialize({ includeDisabled: false });
1605
+ const supersededChunks = [];
1606
+ let completedUploads = 0;
1135
1607
 
1136
1608
  for (const upload of pending) {
1137
1609
  console.log(chalk.cyan(`\n📤 Resuming: ${upload.filename}`));
@@ -1147,12 +1619,20 @@ program
1147
1619
 
1148
1620
  console.log(chalk.dim(` ↑ Uploading chunk ${chunk.chunk_index + 1}/${upload.total_chunks}...`));
1149
1621
 
1150
- const caption = upload.total_chunks > 1
1151
- ? `📦 ${upload.filename} (${chunk.chunk_index + 1}/${upload.total_chunks})`
1152
- : `📦 ${upload.filename}`;
1622
+ const caption = `tas:c1:${upload.id}:${chunk.chunk_index + 1}/${upload.total_chunks}`;
1153
1623
 
1154
- const result = await client.sendFile(chunk.chunk_path, caption);
1155
- db.markChunkUploaded(upload.id, chunk.chunk_index, result.messageId.toString(), result.fileId);
1624
+ const result = await client.sendFile(chunk.chunk_path, caption, {
1625
+ botId: client.selectBotId(upload.hash, chunk.chunk_index),
1626
+ routingKey: upload.hash,
1627
+ chunkIndex: chunk.chunk_index
1628
+ });
1629
+ db.markChunkUploaded(
1630
+ upload.id,
1631
+ chunk.chunk_index,
1632
+ result.messageId.toString(),
1633
+ result.fileId,
1634
+ result.botId
1635
+ );
1156
1636
 
1157
1637
  // Clean up temp file
1158
1638
  fs.unlinkSync(chunk.chunk_path);
@@ -1161,35 +1641,63 @@ program
1161
1641
  // All chunks uploaded - finalize
1162
1642
  const allChunks = db.getPendingChunks(upload.id);
1163
1643
  if (allChunks.every(c => c.uploaded)) {
1164
- // Add to main files table
1165
- const fileId = db.addFile({
1166
- filename: upload.filename,
1167
- hash: upload.hash,
1168
- originalSize: upload.original_size,
1169
- storedSize: upload.original_size, // Approximate
1170
- chunks: upload.total_chunks,
1171
- compressed: true
1172
- });
1644
+ const existing = db.findByExactName(upload.filename);
1645
+ const existingChunks = existing ? db.getChunks(existing.id) : [];
1646
+ db.db.transaction(() => {
1647
+ const fileId = db.addFile({
1648
+ filename: upload.filename,
1649
+ hash: upload.hash,
1650
+ originalSize: upload.original_size,
1651
+ storedSize: upload.stored_size || Math.max(0, allChunks.reduce((sum, c) => sum + (c.size || 0), 0) - allChunks.length * 64),
1652
+ chunks: upload.total_chunks,
1653
+ compressed: Boolean(upload.compressed)
1654
+ });
1173
1655
 
1174
- // Add chunk records
1175
- for (const chunk of allChunks) {
1176
- db.addChunk(fileId, chunk.chunk_index, chunk.message_id, 0);
1177
- db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
1178
- .run(chunk.file_telegram_id, fileId, chunk.chunk_index);
1179
- }
1656
+ for (const chunk of allChunks) {
1657
+ db.addChunk(
1658
+ fileId,
1659
+ chunk.chunk_index,
1660
+ chunk.message_id,
1661
+ chunk.size || 0,
1662
+ chunk.file_telegram_id,
1663
+ chunk.bot_id || null
1664
+ );
1665
+ }
1666
+ if (existing) db.deleteFileCascade(existing.id);
1667
+ db.deletePendingUpload(upload.id);
1668
+ })();
1180
1669
 
1181
- // Clean up pending record
1182
- db.deletePendingUpload(upload.id);
1670
+ supersededChunks.push(...existingChunks);
1183
1671
  if (upload.temp_dir) {
1184
1672
  try { fs.rmdirSync(upload.temp_dir); } catch (e) { }
1185
1673
  }
1186
1674
 
1187
1675
  console.log(chalk.green(` ✓ Completed: ${upload.filename}`));
1676
+ completedUploads++;
1188
1677
  }
1189
1678
  }
1190
1679
 
1191
- console.log(chalk.green('\n✨ All uploads resumed!\n'));
1680
+ const remainingUploads = db.getPendingUploads().length;
1192
1681
  db.close();
1682
+ let manifestUpdated = completedUploads === 0;
1683
+ if (completedUploads > 0) {
1684
+ try {
1685
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool: client });
1686
+ manifestUpdated = true;
1687
+ for (const chunk of supersededChunks) {
1688
+ try { await client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch { }
1689
+ }
1690
+ } catch (error) {
1691
+ console.log(chalk.yellow(`\n⚠ Uploads resumed, but recovery manifest failed: ${error.message}\n`));
1692
+ }
1693
+ }
1694
+ if (remainingUploads === 0) {
1695
+ const suffix = manifestUpdated ? ' and recovery manifest updated' : '; run `tas index backup` to refresh recovery';
1696
+ console.log(chalk.green(`\n✨ All uploads resumed${suffix}!\n`));
1697
+ } else {
1698
+ console.log(chalk.yellow(`\n⚠ ${remainingUploads} upload(s) remain incomplete. Missing staged chunks cannot be resumed.\n`));
1699
+ process.exitCode = 1;
1700
+ }
1193
1701
 
1194
1702
  } catch (err) {
1195
1703
  console.error(chalk.red('Resume failed:'), err.message);
@@ -1208,6 +1716,7 @@ shareCmd
1208
1716
  .option('-e, --expire <duration>', 'Expiry duration (e.g. 1h, 24h, 7d)', '24h')
1209
1717
  .option('-m, --max-downloads <n>', 'Maximum number of downloads', '1')
1210
1718
  .option('--port <port>', 'HTTP server port', '3000')
1719
+ .option('--host <host>', 'HTTP server bind address (default 127.0.0.1; use 0.0.0.0 for LAN)', '127.0.0.1')
1211
1720
  .option('-p, --password <password>', 'Encryption password')
1212
1721
  .action(async (file, options) => {
1213
1722
  console.log(chalk.cyan('\n🔗 Creating share link...\n'));
@@ -1245,7 +1754,8 @@ shareCmd
1245
1754
  dataDir: DATA_DIR,
1246
1755
  password,
1247
1756
  config,
1248
- port
1757
+ port,
1758
+ host: options.host || '127.0.0.1'
1249
1759
  });
1250
1760
 
1251
1761
  await server.initialize();
@@ -1253,22 +1763,16 @@ shareCmd
1253
1763
 
1254
1764
  spinner.succeed('Share server running!');
1255
1765
 
1256
- // Get local IP for network sharing
1257
- const { networkInterfaces } = await import('os');
1258
- const nets = networkInterfaces();
1259
- let localIP = 'localhost';
1260
- for (const name of Object.keys(nets)) {
1261
- for (const net of nets[name]) {
1262
- if (net.family === 'IPv4' && !net.internal) {
1263
- localIP = net.address;
1264
- break;
1265
- }
1266
- }
1267
- }
1268
-
1269
1766
  console.log(chalk.cyan('\n📎 Share Links:\n'));
1270
1767
  console.log(` ${chalk.white('Local:')} ${chalk.green(`http://localhost:${port}/d/${token}`)}`);
1271
- console.log(` ${chalk.white('Network:')} ${chalk.green(`http://${localIP}:${port}/d/${token}`)}`);
1768
+ if (options.host && options.host !== '127.0.0.1' && options.host !== 'localhost') {
1769
+ console.log(` ${chalk.white('Network:')} ${chalk.green(`http://${options.host === '0.0.0.0' ? '<your-lan-ip>' : options.host}:${port}/d/${token}`)}`);
1770
+ if (options.host === '0.0.0.0') {
1771
+ console.log(chalk.yellow(' ⚠ Bound to all interfaces — anyone on your network can fetch this link until it expires.'));
1772
+ }
1773
+ } else {
1774
+ console.log(chalk.dim(' (LAN sharing disabled — bound to localhost. Re-run with --host 0.0.0.0 to share on your network.)'));
1775
+ }
1272
1776
  console.log();
1273
1777
  console.log(chalk.dim(` File: ${fileRecord.filename}`));
1274
1778
  console.log(chalk.dim(` Expires: ${options.expire}`));
@@ -1369,7 +1873,8 @@ shareCmd
1369
1873
  program
1370
1874
  .command('doctor')
1371
1875
  .description('🩺 Run self-diagnostics and check system health')
1372
- .action(async () => {
1876
+ .option('-p, --password <password>', 'Encryption password (to verify every configured Telegram bot)')
1877
+ .action(async (options) => {
1373
1878
  console.log(chalk.cyan('\n🩺 TAS Doctor — System Health Check\n'));
1374
1879
 
1375
1880
  const checks = [];
@@ -1392,11 +1897,19 @@ program
1392
1897
  if (fs.existsSync(configPath)) {
1393
1898
  try {
1394
1899
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1395
- if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1900
+ if (config.configVersion === 3 && Array.isArray(config.bots)) {
1901
+ const enabled = config.bots.filter(bot => bot.enabled !== false).length;
1902
+ ok(`Config v3 (${config.bots.length} bot(s), ${enabled} enabled)`);
1903
+ if (enabled > 1) warn('Experimental multi-bot mode enabled', MULTI_BOT_WARNING);
1904
+ }
1905
+ else if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1396
1906
  else if (config.botToken) warn('Config v1 (plaintext token)', 'Re-run `tas init` to encrypt token');
1397
1907
  else fail('Config invalid', 'Missing bot token');
1398
1908
 
1399
- if (config.chatId) ok(`Chat ID: ${config.chatId}`);
1909
+ const configuredBots = getBotEntries(config);
1910
+ if (configuredBots.length > 0 && configuredBots.every(bot => bot.chatId !== undefined && bot.chatId !== null)) {
1911
+ ok(`Storage chats configured: ${configuredBots.length}`);
1912
+ }
1400
1913
  else fail('Chat ID missing', 'Run `tas init`');
1401
1914
  } catch (e) {
1402
1915
  fail('Config corrupted', e.message);
@@ -1413,6 +1926,16 @@ program
1413
1926
  db.init();
1414
1927
  const stats = db.getStats();
1415
1928
  ok(`Database: ${stats.file_count} files, ${formatBytes(stats.total_original)} total`);
1929
+ const oversized = db.db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE size > ?')
1930
+ .get(20 * 1000 * 1000).count;
1931
+ if (oversized > 0) {
1932
+ warn(
1933
+ `${oversized} legacy chunk(s) exceed the hosted 20 MB getFile limit`,
1934
+ 'They may require a local Bot API server to recover; new uploads use 19 MiB payloads'
1935
+ );
1936
+ } else {
1937
+ ok('Chunk sizes are hosted Bot API round-trip safe');
1938
+ }
1416
1939
  db.close();
1417
1940
  } catch (e) {
1418
1941
  fail('Database error', e.message);
@@ -1421,18 +1944,20 @@ program
1421
1944
  warn('Database not found', 'Will be created on first upload');
1422
1945
  }
1423
1946
 
1424
- // 5. Check FUSE availability
1947
+ // 5. Check the native FUSE stack with a real mount/read/unmount smoke test
1425
1948
  try {
1426
- await import('fuse-native');
1427
- ok('FUSE support available');
1949
+ const { checkFuseRuntime } = await import('./fuse/mount.js');
1950
+ const fuse = await checkFuseRuntime();
1951
+ if (fuse.supported) ok('FUSE runtime: mount → readdir → unmount passed');
1952
+ else warn('FUSE mount unavailable', fuse.reason);
1428
1953
  } catch (e) {
1429
- warn('FUSE not available', 'Install libfuse for mount support');
1954
+ warn('FUSE smoke test failed', e.message);
1430
1955
  }
1431
1956
 
1432
1957
  // 6. Check disk space
1433
1958
  try {
1434
- const { execSync } = await import('child_process');
1435
- const df = execSync(`df -h "${DATA_DIR}" 2>/dev/null || echo "unknown"`).toString().trim();
1959
+ const { execFileSync } = await import('child_process');
1960
+ const df = execFileSync('df', ['-h', DATA_DIR], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
1436
1961
  const lines = df.split('\n');
1437
1962
  if (lines.length > 1) {
1438
1963
  const parts = lines[1].split(/\s+/);
@@ -1447,6 +1972,29 @@ program
1447
1972
  const iterations = 600000;
1448
1973
  ok(`Encryption: AES-256-GCM, PBKDF2-SHA512 ${iterations.toLocaleString()} iterations`);
1449
1974
 
1975
+ // 8. Telegram connectivity (authenticated only when we can decrypt
1976
+ // the token without prompting — never block doctor on a password).
1977
+ try {
1978
+ const cfgRaw = loadConfig(DATA_DIR);
1979
+ const pw = options.password || process.env.TAS_PASSWORD;
1980
+ if (!cfgRaw) {
1981
+ warn('Telegram connectivity not checked', 'Run `tas init` first');
1982
+ } else if (cfgRaw.botToken) {
1983
+ const client = new TelegramClient(DATA_DIR);
1984
+ const me = await client.initialize(cfgRaw.botToken);
1985
+ ok(`Telegram connectivity: OK (@${me.username})`);
1986
+ } else if ((cfgRaw.encryptedBotToken || Array.isArray(cfgRaw.bots)) && pw) {
1987
+ const cfg = resolveConfig(cfgRaw, pw);
1988
+ const client = new TelegramPool(DATA_DIR, cfg.bots);
1989
+ await client.initialize();
1990
+ ok(`Telegram connectivity: ${cfg.bots.length}/${cfg.bots.length} bot(s) OK`);
1991
+ } else {
1992
+ warn('Telegram connectivity not checked', 'Set TAS_PASSWORD or use --password to verify');
1993
+ }
1994
+ } catch (e) {
1995
+ fail('Telegram connectivity failed', e.message);
1996
+ }
1997
+
1450
1998
  // Summary
1451
1999
  const fails = checks.filter(c => c.status === 'fail').length;
1452
2000
  const warns = checks.filter(c => c.status === 'warn').length;
@@ -1458,5 +2006,3 @@ program
1458
2006
  });
1459
2007
 
1460
2008
  program.parse();
1461
-
1462
-