@nightowne/tas-cli 2.4.0 → 3.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 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 only)')
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: TAS mount is currently unsupported (push/pull/sync still work)\n'));
509
876
  process.exit(1);
510
877
  }
511
878
  });
@@ -521,13 +888,13 @@ 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
893
  // Use fusermount on Linux, umount on macOS
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 ? 'umount' : 'fusermount';
896
+ const args = isMac ? [absMount] : ['-u', absMount];
897
+ execFileSync(executable, args, { stdio: 'pipe' });
531
898
 
532
899
  spinner.succeed(`Unmounted ${chalk.green(absMount)}`);
533
900
  } catch (err) {
@@ -545,10 +912,12 @@ const tagCmd = program
545
912
  tagCmd
546
913
  .command('add <file> <tags...>')
547
914
  .description('Add tags to a file')
548
- .action(async (file, tags) => {
915
+ .option('-p, --password <password>', 'Encryption password (or TAS_PASSWORD)')
916
+ .action(async (file, tags, options) => {
549
917
  try {
550
918
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
551
919
  db.init();
920
+ const before = db.exportManifest({ includeShares: true });
552
921
 
553
922
  const fileRecord = db.findByHash(file) || db.findByName(file);
554
923
  if (!fileRecord) {
@@ -560,6 +929,16 @@ tagCmd
560
929
  db.addTag(fileRecord.id, tag);
561
930
  }
562
931
 
932
+ const rawConfig = requireConfig(DATA_DIR);
933
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
934
+ const config = resolveConfig(rawConfig, password);
935
+ try {
936
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config });
937
+ } catch (error) {
938
+ db.importManifest(before);
939
+ throw new Error(`Tag update rolled back because the recovery manifest failed: ${error.message}`);
940
+ }
941
+
563
942
  const allTags = db.getFileTags(fileRecord.id);
564
943
  console.log(chalk.green(`✓ Tags updated for "${fileRecord.filename}"`));
565
944
  console.log(chalk.dim(` Tags: ${allTags.join(', ')}`));
@@ -574,10 +953,12 @@ tagCmd
574
953
  tagCmd
575
954
  .command('remove <file> <tags...>')
576
955
  .description('Remove tags from a file')
577
- .action(async (file, tags) => {
956
+ .option('-p, --password <password>', 'Encryption password (or TAS_PASSWORD)')
957
+ .action(async (file, tags, options) => {
578
958
  try {
579
959
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
580
960
  db.init();
961
+ const before = db.exportManifest({ includeShares: true });
581
962
 
582
963
  const fileRecord = db.findByHash(file) || db.findByName(file);
583
964
  if (!fileRecord) {
@@ -589,6 +970,16 @@ tagCmd
589
970
  db.removeTag(fileRecord.id, tag);
590
971
  }
591
972
 
973
+ const rawConfig = requireConfig(DATA_DIR);
974
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
975
+ const config = resolveConfig(rawConfig, password);
976
+ try {
977
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config });
978
+ } catch (error) {
979
+ db.importManifest(before);
980
+ throw new Error(`Tag update rolled back because the recovery manifest failed: ${error.message}`);
981
+ }
982
+
592
983
  const allTags = db.getFileTags(fileRecord.id);
593
984
  console.log(chalk.green(`✓ Tags updated for "${fileRecord.filename}"`));
594
985
  console.log(chalk.dim(` Tags: ${allTags.length > 0 ? allTags.join(', ') : '(none)'}`));
@@ -744,7 +1135,7 @@ syncCmd
744
1135
  if (options.limit) {
745
1136
  const match = options.limit.match(/^(\d+)([kmg]?)$/i);
746
1137
  if (!match) {
747
- console.error(chalk.red('Invalid limit format. Use e.g. 500{}, 1m'));
1138
+ console.error(chalk.red('Invalid limit format. Use e.g. 500k, 1m'));
748
1139
  process.exit(1);
749
1140
  }
750
1141
  const val = parseInt(match[1]);
@@ -790,6 +1181,10 @@ syncCmd
790
1181
  console.log(chalk.red(` ✗ Failed: ${file} - ${error}`));
791
1182
  });
792
1183
 
1184
+ syncEngine.on('manifest-error', ({ error }) => {
1185
+ console.log(chalk.yellow(` ⚠ Files synced, but remote recovery manifest failed: ${error}`));
1186
+ });
1187
+
793
1188
  syncEngine.on('watch-start', ({ folder }) => {
794
1189
  console.log(chalk.cyan(`👁️ Watching: ${folder}`));
795
1190
  });
@@ -830,6 +1225,7 @@ syncCmd
830
1225
  const rawConfig = requireConfig(DATA_DIR);
831
1226
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
832
1227
  const config = resolveConfig(rawConfig, password);
1228
+ warnIfMultiBot(config);
833
1229
 
834
1230
  const spinner = ora('Loading...').start();
835
1231
 
@@ -852,52 +1248,63 @@ syncCmd
852
1248
 
853
1249
  spinner.succeed(`Found ${files.length} files in Telegram`);
854
1250
 
855
- // Download each file that matches a sync folder
1251
+ // Download each file that matches a sync folder.
1252
+ // Remote files are stored under their sync relative path
1253
+ // (customName at upload time), so join directly. A local file
1254
+ // is skipped only when its content hash matches the index —
1255
+ // existence alone is not enough (edited files must re-pull).
1256
+ // Each file goes to the first folder only to avoid duplicates
1257
+ // when several folders are registered.
856
1258
  let downloaded = 0;
857
1259
  let skipped = 0;
1260
+ const { hashFile } = await import('./crypto/encryption.js');
1261
+ const telegramPool = new TelegramPool(DATA_DIR, config.bots);
858
1262
 
859
1263
  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);
1264
+ const folder = folders[0];
1265
+ const targetPath = path.join(folder.local_path, file.filename);
864
1266
 
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 });
1267
+ // Skip only when local content already matches the index
1268
+ if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) {
1269
+ try {
1270
+ const localHash = await hashFile(targetPath);
1271
+ if (localHash === file.hash) {
1272
+ skipped++;
1273
+ continue;
1274
+ }
1275
+ console.log(chalk.yellow(` ↻ Updating modified file: ${file.filename}`));
1276
+ } catch {
1277
+ // Hash failed — fall through and re-download
875
1278
  }
1279
+ }
876
1280
 
877
- console.log(chalk.dim(` ↓ Downloading: ${file.filename}`));
1281
+ // Ensure directory exists
1282
+ const targetDir = path.dirname(targetPath);
1283
+ if (!fs.existsSync(targetDir)) {
1284
+ fs.mkdirSync(targetDir, { recursive: true });
1285
+ }
878
1286
 
879
- try {
880
- await retrieveFile(file, {
881
- password,
882
- dataDir: DATA_DIR,
883
- outputPath: targetPath,
884
- config,
885
- onProgress: () => { }
886
- });
1287
+ console.log(chalk.dim(` ↓ Downloading: ${file.filename}`));
887
1288
 
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);
1289
+ try {
1290
+ await retrieveFile(file, {
1291
+ password,
1292
+ dataDir: DATA_DIR,
1293
+ outputPath: targetPath,
1294
+ config,
1295
+ telegramPool,
1296
+ onProgress: () => { }
1297
+ });
893
1298
 
894
- console.log(chalk.green(` ✓ Downloaded: ${file.filename}`));
895
- downloaded++;
896
- } catch (err) {
897
- console.log(chalk.red(` ✗ Failed: ${file.filename} - ${err.message}`));
898
- }
1299
+ // Update sync state
1300
+ const hash = await hashFile(targetPath);
1301
+ const stats = fs.statSync(targetPath);
1302
+ db.updateSyncState(folder.id, file.filename, hash, stats.mtimeMs);
899
1303
 
900
- break; // Only download to first matching folder
1304
+ console.log(chalk.green(` ✓ Downloaded: ${file.filename}`));
1305
+ downloaded++;
1306
+ } catch (err) {
1307
+ console.log(chalk.red(` ✗ Failed: ${file.filename} - ${err.message}`));
901
1308
  }
902
1309
  }
903
1310
 
@@ -913,14 +1320,16 @@ syncCmd
913
1320
  // ============== VERIFY COMMAND ==============
914
1321
  program
915
1322
  .command('verify')
916
- .description('Verify file integrity and check for missing Telegram messages')
1323
+ .description('Check Telegram references; use --deep to download, decrypt, and hash every file')
917
1324
  .option('-p, --password <password>', 'Encryption password')
1325
+ .option('--deep', 'Download, authenticate, decompress, and SHA-256 verify every file')
918
1326
  .action(async (options) => {
919
1327
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
920
1328
 
921
1329
  const rawConfig = requireConfig(DATA_DIR);
922
1330
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
923
1331
  const config = resolveConfig(rawConfig, password);
1332
+ warnIfMultiBot(config);
924
1333
 
925
1334
  const spinner = ora('Checking files...').start();
926
1335
 
@@ -936,15 +1345,14 @@ program
936
1345
 
937
1346
  spinner.text = 'Connecting to Telegram...';
938
1347
 
939
- const client = new TelegramClient(DATA_DIR);
940
- await client.initialize(config.botToken);
941
- client.setChatId(config.chatId);
1348
+ const client = new TelegramPool(DATA_DIR, config.bots);
942
1349
 
943
1350
  spinner.succeed(`Checking ${files.length} files...`);
944
1351
 
945
1352
  let valid = 0;
946
1353
  let missing = 0;
947
1354
  let errors = [];
1355
+ const verifyDir = options.deep ? fs.mkdtempSync(path.join(os.tmpdir(), 'tas-verify-')) : null;
948
1356
 
949
1357
  for (const file of files) {
950
1358
  const chunks = db.getChunks(file.id);
@@ -960,7 +1368,7 @@ program
960
1368
  }
961
1369
 
962
1370
  // Check if file is accessible (will throw if deleted)
963
- await client.bot.getFile(chunk.file_telegram_id);
1371
+ await client.getFile(chunk.file_telegram_id, chunk.bot_id || null);
964
1372
  } catch (err) {
965
1373
  fileValid = false;
966
1374
  errors.push({
@@ -972,6 +1380,23 @@ program
972
1380
  }
973
1381
  }
974
1382
 
1383
+ if (fileValid && options.deep) {
1384
+ try {
1385
+ const verifyPath = path.join(verifyDir, String(file.id));
1386
+ await retrieveFile(file, {
1387
+ password,
1388
+ dataDir: DATA_DIR,
1389
+ outputPath: verifyPath,
1390
+ config,
1391
+ telegramPool: client
1392
+ });
1393
+ try { fs.unlinkSync(verifyPath); } catch { }
1394
+ } catch (error) {
1395
+ fileValid = false;
1396
+ errors.push({ file: file.filename, error: `Deep verification failed: ${error.message}` });
1397
+ }
1398
+ }
1399
+
975
1400
  if (fileValid) {
976
1401
  console.log(` ${chalk.green('✓')} ${file.filename}`);
977
1402
  valid++;
@@ -982,6 +1407,9 @@ program
982
1407
  }
983
1408
 
984
1409
  console.log();
1410
+ if (verifyDir) {
1411
+ try { fs.rmSync(verifyDir, { recursive: true, force: true }); } catch { }
1412
+ }
985
1413
  console.log(chalk.cyan('📊 Verification Results'));
986
1414
  console.log(` Valid: ${chalk.green(valid)}`);
987
1415
  console.log(` Missing: ${chalk.red(missing)}`);
@@ -1061,13 +1489,48 @@ program
1061
1489
  db.init();
1062
1490
 
1063
1491
  const pending = db.getPendingUploads();
1492
+ // Leftovers from pre-2.5 interrupted uploads (before processFile
1493
+ // cleaned up partial rows atomically). Offer to clear them so a
1494
+ // retry doesn't hit a phantom "duplicate hash".
1495
+ let orphans = [];
1496
+ try { orphans = db.getIncompleteUploads(); } catch { orphans = []; }
1064
1497
 
1065
- if (pending.length === 0) {
1498
+ if (pending.length === 0 && orphans.length === 0) {
1066
1499
  console.log(chalk.yellow('\n📭 No interrupted uploads found.\n'));
1067
1500
  db.close();
1068
1501
  return;
1069
1502
  }
1070
1503
 
1504
+ if (orphans.length > 0) {
1505
+ console.log(chalk.yellow(`\n⚠ ${orphans.length} incomplete file record(s) from interrupted uploads (pre-2.5):\n`));
1506
+ for (const o of orphans) {
1507
+ console.log(` ${chalk.blue('●')} ${o.filename} ${chalk.dim(`(${o.actual_chunks}/${o.chunks} chunks)`)}`);
1508
+ }
1509
+ console.log(chalk.dim('\n Current versions clean up partial uploads automatically.'));
1510
+ console.log(chalk.dim(' Clear these leftovers, then re-run `tas push <file>` to retry.\n'));
1511
+
1512
+ const { clearOrphans } = await inquirer.prompt([
1513
+ {
1514
+ type: 'confirm',
1515
+ name: 'clearOrphans',
1516
+ message: 'Delete incomplete file records now?',
1517
+ default: true
1518
+ }
1519
+ ]);
1520
+ if (clearOrphans) {
1521
+ for (const o of orphans) {
1522
+ try { db.deleteFileCascade(o.id); } catch { }
1523
+ }
1524
+ console.log(chalk.green('✓ Cleared incomplete uploads — retry with `tas push <file>`'));
1525
+ }
1526
+ orphans = [];
1527
+ }
1528
+
1529
+ if (pending.length === 0) {
1530
+ db.close();
1531
+ return;
1532
+ }
1533
+
1071
1534
  console.log(chalk.cyan(`\n🔄 Pending Uploads (${pending.length})\n`));
1072
1535
 
1073
1536
  for (const upload of pending) {
@@ -1099,10 +1562,17 @@ program
1099
1562
  }
1100
1563
 
1101
1564
  if (action === 'clear') {
1565
+ const rawConfig = requireConfig(DATA_DIR);
1566
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1567
+ const config = resolveConfig(rawConfig, password);
1568
+ const client = new TelegramPool(DATA_DIR, config.bots);
1102
1569
  for (const upload of pending) {
1103
1570
  // Clean up temp files
1104
1571
  const chunks = db.getPendingChunks(upload.id);
1105
1572
  for (const chunk of chunks) {
1573
+ if (chunk.uploaded && chunk.message_id) {
1574
+ try { await client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch { }
1575
+ }
1106
1576
  try { fs.unlinkSync(chunk.chunk_path); } catch (e) { }
1107
1577
  }
1108
1578
  if (upload.temp_dir) {
@@ -1126,12 +1596,13 @@ program
1126
1596
  // Get and verify password
1127
1597
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1128
1598
  const config = resolveConfig(rawConfig, password);
1599
+ warnIfMultiBot(config);
1129
1600
 
1130
1601
  // 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);
1602
+ const client = new TelegramPool(DATA_DIR, config.bots);
1603
+ await client.initialize({ includeDisabled: false });
1604
+ const supersededChunks = [];
1605
+ let completedUploads = 0;
1135
1606
 
1136
1607
  for (const upload of pending) {
1137
1608
  console.log(chalk.cyan(`\n📤 Resuming: ${upload.filename}`));
@@ -1147,12 +1618,20 @@ program
1147
1618
 
1148
1619
  console.log(chalk.dim(` ↑ Uploading chunk ${chunk.chunk_index + 1}/${upload.total_chunks}...`));
1149
1620
 
1150
- const caption = upload.total_chunks > 1
1151
- ? `📦 ${upload.filename} (${chunk.chunk_index + 1}/${upload.total_chunks})`
1152
- : `📦 ${upload.filename}`;
1621
+ const caption = `tas:c1:${upload.id}:${chunk.chunk_index + 1}/${upload.total_chunks}`;
1153
1622
 
1154
- const result = await client.sendFile(chunk.chunk_path, caption);
1155
- db.markChunkUploaded(upload.id, chunk.chunk_index, result.messageId.toString(), result.fileId);
1623
+ const result = await client.sendFile(chunk.chunk_path, caption, {
1624
+ botId: client.selectBotId(upload.hash, chunk.chunk_index),
1625
+ routingKey: upload.hash,
1626
+ chunkIndex: chunk.chunk_index
1627
+ });
1628
+ db.markChunkUploaded(
1629
+ upload.id,
1630
+ chunk.chunk_index,
1631
+ result.messageId.toString(),
1632
+ result.fileId,
1633
+ result.botId
1634
+ );
1156
1635
 
1157
1636
  // Clean up temp file
1158
1637
  fs.unlinkSync(chunk.chunk_path);
@@ -1161,35 +1640,63 @@ program
1161
1640
  // All chunks uploaded - finalize
1162
1641
  const allChunks = db.getPendingChunks(upload.id);
1163
1642
  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
- });
1643
+ const existing = db.findByExactName(upload.filename);
1644
+ const existingChunks = existing ? db.getChunks(existing.id) : [];
1645
+ db.db.transaction(() => {
1646
+ const fileId = db.addFile({
1647
+ filename: upload.filename,
1648
+ hash: upload.hash,
1649
+ originalSize: upload.original_size,
1650
+ storedSize: upload.stored_size || Math.max(0, allChunks.reduce((sum, c) => sum + (c.size || 0), 0) - allChunks.length * 64),
1651
+ chunks: upload.total_chunks,
1652
+ compressed: Boolean(upload.compressed)
1653
+ });
1173
1654
 
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
- }
1655
+ for (const chunk of allChunks) {
1656
+ db.addChunk(
1657
+ fileId,
1658
+ chunk.chunk_index,
1659
+ chunk.message_id,
1660
+ chunk.size || 0,
1661
+ chunk.file_telegram_id,
1662
+ chunk.bot_id || null
1663
+ );
1664
+ }
1665
+ if (existing) db.deleteFileCascade(existing.id);
1666
+ db.deletePendingUpload(upload.id);
1667
+ })();
1180
1668
 
1181
- // Clean up pending record
1182
- db.deletePendingUpload(upload.id);
1669
+ supersededChunks.push(...existingChunks);
1183
1670
  if (upload.temp_dir) {
1184
1671
  try { fs.rmdirSync(upload.temp_dir); } catch (e) { }
1185
1672
  }
1186
1673
 
1187
1674
  console.log(chalk.green(` ✓ Completed: ${upload.filename}`));
1675
+ completedUploads++;
1188
1676
  }
1189
1677
  }
1190
1678
 
1191
- console.log(chalk.green('\n✨ All uploads resumed!\n'));
1679
+ const remainingUploads = db.getPendingUploads().length;
1192
1680
  db.close();
1681
+ let manifestUpdated = completedUploads === 0;
1682
+ if (completedUploads > 0) {
1683
+ try {
1684
+ await backupRemoteManifest({ dataDir: DATA_DIR, password, config, telegramPool: client });
1685
+ manifestUpdated = true;
1686
+ for (const chunk of supersededChunks) {
1687
+ try { await client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch { }
1688
+ }
1689
+ } catch (error) {
1690
+ console.log(chalk.yellow(`\n⚠ Uploads resumed, but recovery manifest failed: ${error.message}\n`));
1691
+ }
1692
+ }
1693
+ if (remainingUploads === 0) {
1694
+ const suffix = manifestUpdated ? ' and recovery manifest updated' : '; run `tas index backup` to refresh recovery';
1695
+ console.log(chalk.green(`\n✨ All uploads resumed${suffix}!\n`));
1696
+ } else {
1697
+ console.log(chalk.yellow(`\n⚠ ${remainingUploads} upload(s) remain incomplete. Missing staged chunks cannot be resumed.\n`));
1698
+ process.exitCode = 1;
1699
+ }
1193
1700
 
1194
1701
  } catch (err) {
1195
1702
  console.error(chalk.red('Resume failed:'), err.message);
@@ -1208,6 +1715,7 @@ shareCmd
1208
1715
  .option('-e, --expire <duration>', 'Expiry duration (e.g. 1h, 24h, 7d)', '24h')
1209
1716
  .option('-m, --max-downloads <n>', 'Maximum number of downloads', '1')
1210
1717
  .option('--port <port>', 'HTTP server port', '3000')
1718
+ .option('--host <host>', 'HTTP server bind address (default 127.0.0.1; use 0.0.0.0 for LAN)', '127.0.0.1')
1211
1719
  .option('-p, --password <password>', 'Encryption password')
1212
1720
  .action(async (file, options) => {
1213
1721
  console.log(chalk.cyan('\n🔗 Creating share link...\n'));
@@ -1245,7 +1753,8 @@ shareCmd
1245
1753
  dataDir: DATA_DIR,
1246
1754
  password,
1247
1755
  config,
1248
- port
1756
+ port,
1757
+ host: options.host || '127.0.0.1'
1249
1758
  });
1250
1759
 
1251
1760
  await server.initialize();
@@ -1253,22 +1762,16 @@ shareCmd
1253
1762
 
1254
1763
  spinner.succeed('Share server running!');
1255
1764
 
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
1765
  console.log(chalk.cyan('\n📎 Share Links:\n'));
1270
1766
  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}`)}`);
1767
+ if (options.host && options.host !== '127.0.0.1' && options.host !== 'localhost') {
1768
+ console.log(` ${chalk.white('Network:')} ${chalk.green(`http://${options.host === '0.0.0.0' ? '<your-lan-ip>' : options.host}:${port}/d/${token}`)}`);
1769
+ if (options.host === '0.0.0.0') {
1770
+ console.log(chalk.yellow(' ⚠ Bound to all interfaces — anyone on your network can fetch this link until it expires.'));
1771
+ }
1772
+ } else {
1773
+ console.log(chalk.dim(' (LAN sharing disabled — bound to localhost. Re-run with --host 0.0.0.0 to share on your network.)'));
1774
+ }
1272
1775
  console.log();
1273
1776
  console.log(chalk.dim(` File: ${fileRecord.filename}`));
1274
1777
  console.log(chalk.dim(` Expires: ${options.expire}`));
@@ -1369,7 +1872,8 @@ shareCmd
1369
1872
  program
1370
1873
  .command('doctor')
1371
1874
  .description('🩺 Run self-diagnostics and check system health')
1372
- .action(async () => {
1875
+ .option('-p, --password <password>', 'Encryption password (to verify every configured Telegram bot)')
1876
+ .action(async (options) => {
1373
1877
  console.log(chalk.cyan('\n🩺 TAS Doctor — System Health Check\n'));
1374
1878
 
1375
1879
  const checks = [];
@@ -1392,11 +1896,19 @@ program
1392
1896
  if (fs.existsSync(configPath)) {
1393
1897
  try {
1394
1898
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1395
- if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1899
+ if (config.configVersion === 3 && Array.isArray(config.bots)) {
1900
+ const enabled = config.bots.filter(bot => bot.enabled !== false).length;
1901
+ ok(`Config v3 (${config.bots.length} bot(s), ${enabled} enabled)`);
1902
+ if (enabled > 1) warn('Experimental multi-bot mode enabled', MULTI_BOT_WARNING);
1903
+ }
1904
+ else if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1396
1905
  else if (config.botToken) warn('Config v1 (plaintext token)', 'Re-run `tas init` to encrypt token');
1397
1906
  else fail('Config invalid', 'Missing bot token');
1398
1907
 
1399
- if (config.chatId) ok(`Chat ID: ${config.chatId}`);
1908
+ const configuredBots = getBotEntries(config);
1909
+ if (configuredBots.length > 0 && configuredBots.every(bot => bot.chatId !== undefined && bot.chatId !== null)) {
1910
+ ok(`Storage chats configured: ${configuredBots.length}`);
1911
+ }
1400
1912
  else fail('Chat ID missing', 'Run `tas init`');
1401
1913
  } catch (e) {
1402
1914
  fail('Config corrupted', e.message);
@@ -1413,6 +1925,16 @@ program
1413
1925
  db.init();
1414
1926
  const stats = db.getStats();
1415
1927
  ok(`Database: ${stats.file_count} files, ${formatBytes(stats.total_original)} total`);
1928
+ const oversized = db.db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE size > ?')
1929
+ .get(20 * 1000 * 1000).count;
1930
+ if (oversized > 0) {
1931
+ warn(
1932
+ `${oversized} legacy chunk(s) exceed the hosted 20 MB getFile limit`,
1933
+ 'They may require a local Bot API server to recover; new uploads use 19 MiB payloads'
1934
+ );
1935
+ } else {
1936
+ ok('Chunk sizes are hosted Bot API round-trip safe');
1937
+ }
1416
1938
  db.close();
1417
1939
  } catch (e) {
1418
1940
  fail('Database error', e.message);
@@ -1421,18 +1943,20 @@ program
1421
1943
  warn('Database not found', 'Will be created on first upload');
1422
1944
  }
1423
1945
 
1424
- // 5. Check FUSE availability
1946
+ // 5. Check the native FUSE stack with a real mount/read/unmount smoke test
1425
1947
  try {
1426
- await import('fuse-native');
1427
- ok('FUSE support available');
1948
+ const { checkFuseRuntime } = await import('./fuse/mount.js');
1949
+ const fuse = await checkFuseRuntime();
1950
+ if (fuse.supported) ok('FUSE runtime: mount → readdir → unmount passed');
1951
+ else warn('FUSE mount unavailable', fuse.reason);
1428
1952
  } catch (e) {
1429
- warn('FUSE not available', 'Install libfuse for mount support');
1953
+ warn('FUSE smoke test failed', e.message);
1430
1954
  }
1431
1955
 
1432
1956
  // 6. Check disk space
1433
1957
  try {
1434
- const { execSync } = await import('child_process');
1435
- const df = execSync(`df -h "${DATA_DIR}" 2>/dev/null || echo "unknown"`).toString().trim();
1958
+ const { execFileSync } = await import('child_process');
1959
+ const df = execFileSync('df', ['-h', DATA_DIR], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
1436
1960
  const lines = df.split('\n');
1437
1961
  if (lines.length > 1) {
1438
1962
  const parts = lines[1].split(/\s+/);
@@ -1447,6 +1971,29 @@ program
1447
1971
  const iterations = 600000;
1448
1972
  ok(`Encryption: AES-256-GCM, PBKDF2-SHA512 ${iterations.toLocaleString()} iterations`);
1449
1973
 
1974
+ // 8. Telegram connectivity (authenticated only when we can decrypt
1975
+ // the token without prompting — never block doctor on a password).
1976
+ try {
1977
+ const cfgRaw = loadConfig(DATA_DIR);
1978
+ const pw = options.password || process.env.TAS_PASSWORD;
1979
+ if (!cfgRaw) {
1980
+ warn('Telegram connectivity not checked', 'Run `tas init` first');
1981
+ } else if (cfgRaw.botToken) {
1982
+ const client = new TelegramClient(DATA_DIR);
1983
+ const me = await client.initialize(cfgRaw.botToken);
1984
+ ok(`Telegram connectivity: OK (@${me.username})`);
1985
+ } else if ((cfgRaw.encryptedBotToken || Array.isArray(cfgRaw.bots)) && pw) {
1986
+ const cfg = resolveConfig(cfgRaw, pw);
1987
+ const client = new TelegramPool(DATA_DIR, cfg.bots);
1988
+ await client.initialize();
1989
+ ok(`Telegram connectivity: ${cfg.bots.length}/${cfg.bots.length} bot(s) OK`);
1990
+ } else {
1991
+ warn('Telegram connectivity not checked', 'Set TAS_PASSWORD or use --password to verify');
1992
+ }
1993
+ } catch (e) {
1994
+ fail('Telegram connectivity failed', e.message);
1995
+ }
1996
+
1450
1997
  // Summary
1451
1998
  const fails = checks.filter(c => c.status === 'fail').length;
1452
1999
  const warns = checks.filter(c => c.status === 'warn').length;
@@ -1458,5 +2005,3 @@ program
1458
2005
  });
1459
2006
 
1460
2007
  program.parse();
1461
-
1462
-