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