@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/CHANGELOG.md +154 -0
- package/FAQ.md +33 -0
- package/QUICKSTART.md +47 -0
- package/README.md +356 -196
- package/package.json +11 -5
- package/src/cli.js +758 -213
- package/src/db/index.js +307 -24
- package/src/fuse/mount.js +336 -175
- package/src/index.js +187 -123
- package/src/manifest.js +104 -0
- package/src/share/server.js +7 -9
- package/src/sync/sync.js +105 -34
- package/src/telegram/client.js +10 -3
- package/src/telegram/pool.js +105 -0
- package/src/utils/branding.js +2 -2
- package/src/utils/chunker.js +4 -3
- package/src/utils/cli-helpers.js +90 -10
- package/src/utils/download-stream.js +10 -4
- package/src/utils/logical-path.js +44 -0
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 {
|
|
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(' -
|
|
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
|
-
.
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
135
|
-
}
|
|
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
|
-
// ==============
|
|
162
|
-
program
|
|
163
|
-
.command('
|
|
164
|
-
.description('
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
progressBar
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
console.log(chalk.dim(` Size: ${formatBytes(result.originalSize)} → ${formatBytes(result.storedSize)}`));
|
|
215
|
-
console.log(chalk.dim(` Chunks: ${result.chunks}`));
|
|
556
|
+
}
|
|
216
557
|
|
|
217
|
-
|
|
218
|
-
|
|
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 (
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
await
|
|
377
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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.
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
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.
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
1946
|
+
// 5. Check the native FUSE stack with a real mount/read/unmount smoke test
|
|
1425
1947
|
try {
|
|
1426
|
-
await import('fuse
|
|
1427
|
-
|
|
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
|
|
1953
|
+
warn('FUSE smoke test failed', e.message);
|
|
1430
1954
|
}
|
|
1431
1955
|
|
|
1432
1956
|
// 6. Check disk space
|
|
1433
1957
|
try {
|
|
1434
|
-
const {
|
|
1435
|
-
const df =
|
|
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
|
-
|