@nightowne/tas-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync/sync.js CHANGED
@@ -13,6 +13,10 @@ import { processFile } from '../index.js';
13
13
  // Debounce time in ms to batch rapid file changes
14
14
  const DEBOUNCE_MS = 1000;
15
15
 
16
+ // Exponential backoff limits for upload errors
17
+ const INITIAL_BACKOFF_MS = 2000;
18
+ const MAX_BACKOFF_MS = 60000;
19
+
16
20
  // Ignore patterns
17
21
  const IGNORE_PATTERNS = [
18
22
  /^\./, // Hidden files
@@ -31,6 +35,7 @@ export class SyncEngine extends EventEmitter {
31
35
  this.config = options.config;
32
36
  this.limitRate = options.limitRate || null;
33
37
  this.watchers = new Map(); // path -> FSWatcher
38
+ this.rootWatchers = new Map(); // rootPath -> Set of watched subpaths (for Linux recursive watch fallback)
34
39
  this.pendingChanges = new Map(); // path -> timeout
35
40
  this.db = null;
36
41
  this.running = false;
@@ -105,6 +110,8 @@ export class SyncEngine extends EventEmitter {
105
110
  const promises = [];
106
111
 
107
112
  const worker = async () => {
113
+ let consecutiveErrors = 0;
114
+
108
115
  while (queue.length > 0) {
109
116
  const file = queue.shift();
110
117
  const existing = stateMap.get(file.relativePath);
@@ -141,6 +148,7 @@ export class SyncEngine extends EventEmitter {
141
148
  // Update sync state
142
149
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
143
150
  uploaded++;
151
+ consecutiveErrors = 0; // Reset on success
144
152
 
145
153
  this.emit('file-upload-complete', { file: file.relativePath });
146
154
  } catch (err) {
@@ -148,9 +156,12 @@ export class SyncEngine extends EventEmitter {
148
156
  if (err.message.includes('duplicate')) {
149
157
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
150
158
  skipped++;
159
+ consecutiveErrors = 0;
151
160
  } else {
152
- // Sleep briefly on non-duplicate error (potential rate limits)
153
- await new Promise(r => setTimeout(r, 2000));
161
+ consecutiveErrors++;
162
+ // Exponential backoff: 2s → 4s → 8s → 16s → ... → 60s max
163
+ const delay = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, consecutiveErrors - 1), MAX_BACKOFF_MS);
164
+ await new Promise(r => setTimeout(r, delay));
154
165
  this.emit('file-upload-error', { file: file.relativePath, error: err.message });
155
166
  }
156
167
  }
@@ -238,37 +249,148 @@ export class SyncEngine extends EventEmitter {
238
249
  }
239
250
  }
240
251
 
252
+ /**
253
+ * Recursively list all subdirectories of a directory
254
+ */
255
+ getSubdirectories(dirPath) {
256
+ const subdirs = [];
257
+ try {
258
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
259
+ for (const entry of entries) {
260
+ if (entry.isDirectory()) {
261
+ if (this.shouldIgnore(entry.name)) continue;
262
+ const fullPath = path.join(dirPath, entry.name);
263
+ subdirs.push(fullPath);
264
+ subdirs.push(...this.getSubdirectories(fullPath));
265
+ }
266
+ }
267
+ } catch (err) {
268
+ // Ignore folder reading errors
269
+ }
270
+ return subdirs;
271
+ }
272
+
273
+ /**
274
+ * Watch a single directory (non-recursively) and dynamically watch new folders
275
+ */
276
+ watchSingleDir(rootPath, dir) {
277
+ if (this.watchers.has(dir)) return;
278
+
279
+ try {
280
+ const watcher = fs.watch(dir, { recursive: false }, (event, filename) => {
281
+ if (!filename) return;
282
+
283
+ const fullPath = path.join(dir, filename);
284
+ const relativePath = path.relative(rootPath, fullPath);
285
+
286
+ // If a new directory is created, watch it recursively
287
+ try {
288
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
289
+ if (!this.shouldIgnore(filename)) {
290
+ this.watchDirRecursively(rootPath, fullPath);
291
+ }
292
+ }
293
+ } catch (e) {
294
+ // Ignore stats errors
295
+ }
296
+
297
+ this.handleFileChange(rootPath, relativePath);
298
+ });
299
+
300
+ watcher.on('error', (err) => {
301
+ this.emit('watch-error', { folder: rootPath, error: err.message });
302
+ });
303
+
304
+ this.watchers.set(dir, watcher);
305
+
306
+ if (!this.rootWatchers.has(rootPath)) {
307
+ this.rootWatchers.set(rootPath, new Set());
308
+ }
309
+ this.rootWatchers.get(rootPath).add(dir);
310
+ } catch (err) {
311
+ this.emit('watch-error', { folder: rootPath, error: err.message });
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Recursively watch a newly created directory and its subdirectories
317
+ */
318
+ watchDirRecursively(rootPath, dirPath) {
319
+ this.watchSingleDir(rootPath, dirPath);
320
+ const subdirs = this.getSubdirectories(dirPath);
321
+ for (const subdir of subdirs) {
322
+ this.watchSingleDir(rootPath, subdir);
323
+ }
324
+ }
325
+
241
326
  /**
242
327
  * Start watching a folder
243
328
  */
244
329
  watchFolder(folderPath) {
245
- if (this.watchers.has(folderPath)) {
246
- return; // Already watching
247
- }
330
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
248
331
 
249
- const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
250
- if (filename) {
251
- this.handleFileChange(folderPath, filename);
332
+ if (isRecursiveSupported) {
333
+ if (this.watchers.has(folderPath)) {
334
+ return; // Already watching
252
335
  }
253
- });
254
336
 
255
- watcher.on('error', (err) => {
256
- this.emit('watch-error', { folder: folderPath, error: err.message });
257
- });
337
+ const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
338
+ if (filename) {
339
+ this.handleFileChange(folderPath, filename);
340
+ }
341
+ });
342
+
343
+ watcher.on('error', (err) => {
344
+ this.emit('watch-error', { folder: folderPath, error: err.message });
345
+ });
258
346
 
259
- this.watchers.set(folderPath, watcher);
260
- this.emit('watch-start', { folder: folderPath });
347
+ this.watchers.set(folderPath, watcher);
348
+ this.emit('watch-start', { folder: folderPath });
349
+ } else {
350
+ // Manual recursive watch for Linux/other platforms
351
+ if (this.rootWatchers.has(folderPath)) {
352
+ return; // Already watching
353
+ }
354
+
355
+ // Watch root
356
+ this.watchSingleDir(folderPath, folderPath);
357
+
358
+ // Watch all existing subdirectories
359
+ const subdirs = this.getSubdirectories(folderPath);
360
+ for (const subdir of subdirs) {
361
+ this.watchSingleDir(folderPath, subdir);
362
+ }
363
+
364
+ this.emit('watch-start', { folder: folderPath });
365
+ }
261
366
  }
262
367
 
263
368
  /**
264
369
  * Stop watching a folder
265
370
  */
266
371
  unwatchFolder(folderPath) {
267
- const watcher = this.watchers.get(folderPath);
268
- if (watcher) {
269
- watcher.close();
270
- this.watchers.delete(folderPath);
271
- this.emit('watch-stop', { folder: folderPath });
372
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
373
+
374
+ if (isRecursiveSupported) {
375
+ const watcher = this.watchers.get(folderPath);
376
+ if (watcher) {
377
+ watcher.close();
378
+ this.watchers.delete(folderPath);
379
+ this.emit('watch-stop', { folder: folderPath });
380
+ }
381
+ } else {
382
+ if (this.rootWatchers.has(folderPath)) {
383
+ const dirs = this.rootWatchers.get(folderPath);
384
+ for (const dir of dirs) {
385
+ const watcher = this.watchers.get(dir);
386
+ if (watcher) {
387
+ watcher.close();
388
+ this.watchers.delete(dir);
389
+ }
390
+ }
391
+ this.rootWatchers.delete(folderPath);
392
+ this.emit('watch-stop', { folder: folderPath });
393
+ }
272
394
  }
273
395
  }
274
396
 
@@ -302,11 +424,25 @@ export class SyncEngine extends EventEmitter {
302
424
  this.pendingChanges.clear();
303
425
 
304
426
  // Close all watchers
305
- for (const [folderPath, watcher] of this.watchers) {
306
- watcher.close();
307
- this.emit('watch-stop', { folder: folderPath });
427
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
428
+ if (isRecursiveSupported) {
429
+ for (const [folderPath, watcher] of this.watchers) {
430
+ watcher.close();
431
+ this.emit('watch-stop', { folder: folderPath });
432
+ }
433
+ this.watchers.clear();
434
+ } else {
435
+ for (const rootPath of this.rootWatchers.keys()) {
436
+ this.unwatchFolder(rootPath);
437
+ }
438
+ this.rootWatchers.clear();
439
+
440
+ // Just in case, close any stray watchers
441
+ for (const watcher of this.watchers.values()) {
442
+ try { watcher.close(); } catch (e) {}
443
+ }
444
+ this.watchers.clear();
308
445
  }
309
- this.watchers.clear();
310
446
 
311
447
  if (this.db) {
312
448
  this.db.close();
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Telegram Bot client wrapper
3
3
  * Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
4
+ * Includes exponential backoff retry and rate limiting for production reliability.
4
5
  */
5
6
 
6
7
  import TelegramBot from 'node-telegram-bot-api';
@@ -8,11 +9,46 @@ import fs from 'fs';
8
9
  import path from 'path';
9
10
  import { pipeline } from 'stream/promises';
10
11
 
12
+ const MAX_RETRIES = 5;
13
+ const BASE_DELAY_MS = 1000;
14
+ const MAX_DELAY_MS = 60000;
15
+
16
+ /**
17
+ * Retry an async function with exponential backoff + jitter.
18
+ * Handles Telegram 429 (rate limit) errors by respecting retry_after.
19
+ */
20
+ async function withRetry(fn, label = 'operation', retries = MAX_RETRIES) {
21
+ for (let attempt = 0; attempt <= retries; attempt++) {
22
+ try {
23
+ return await fn();
24
+ } catch (err) {
25
+ const isRateLimit = err?.response?.statusCode === 429 || err?.message?.includes('429');
26
+ const isTransient = err?.code === 'ETIMEOUT' || err?.code === 'ECONNRESET' ||
27
+ err?.code === 'ENOTFOUND' || err?.message?.includes('ETIMEDOUT');
28
+
29
+ if (attempt >= retries) throw err;
30
+ if (!isRateLimit && !isTransient) throw err;
31
+
32
+ let delay;
33
+ if (isRateLimit && err?.response?.body?.parameters?.retry_after) {
34
+ delay = err.response.body.parameters.retry_after * 1000 + 500;
35
+ } else {
36
+ delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000, MAX_DELAY_MS);
37
+ }
38
+
39
+ const sec = (delay / 1000).toFixed(1);
40
+ console.log(`⏳ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${sec}s...`);
41
+ await new Promise(r => setTimeout(r, delay));
42
+ }
43
+ }
44
+ }
45
+
11
46
  export class TelegramClient {
12
47
  constructor(dataDir) {
13
48
  this.dataDir = dataDir;
14
49
  this.bot = null;
15
50
  this.chatId = null;
51
+ this._lastSendTime = 0;
16
52
  }
17
53
 
18
54
  /**
@@ -76,9 +112,22 @@ export class TelegramClient {
76
112
  });
77
113
  }
78
114
 
115
+ /**
116
+ * Rate-limit: ensure at least 1s between sends to same chat (Telegram limit)
117
+ */
118
+ async _rateLimit() {
119
+ const now = Date.now();
120
+ const elapsed = now - this._lastSendTime;
121
+ if (elapsed < 1000) {
122
+ await new Promise(r => setTimeout(r, 1000 - elapsed));
123
+ }
124
+ this._lastSendTime = Date.now();
125
+ }
126
+
79
127
  /**
80
128
  * Send a file to the storage chat
81
129
  * Telegram supports up to 2GB for documents!
130
+ * Includes automatic retry with exponential backoff.
82
131
  */
83
132
  async sendFile(filePath, caption = '', options = {}) {
84
133
  if (!this.chatId) {
@@ -89,9 +138,12 @@ export class TelegramClient {
89
138
  throw new Error(`File not found: ${filePath}`);
90
139
  }
91
140
 
92
- try {
141
+ const filename = path.basename(filePath);
142
+
143
+ return withRetry(async () => {
144
+ await this._rateLimit();
145
+
93
146
  let fileStream = fs.createReadStream(filePath);
94
- const filename = path.basename(filePath);
95
147
 
96
148
  if (options.limitRate) {
97
149
  const { Throttle } = await import('../utils/throttle.js');
@@ -110,28 +162,24 @@ export class TelegramClient {
110
162
  fileId: message.document.file_id,
111
163
  timestamp: message.date
112
164
  };
113
- } catch (err) {
114
- throw new Error(`Failed to upload to Telegram: ${err.message}`);
115
- }
165
+ }, `Upload ${filename}`);
116
166
  }
117
167
 
118
168
  /**
119
169
  * Download a file from Telegram (In-Memory buffer)
170
+ * Includes automatic retry with exponential backoff.
120
171
  */
121
172
  async downloadFile(fileId) {
122
- // Get file path from Telegram servers
123
- const file = await this.bot.getFile(fileId);
124
-
125
- // Download the file
126
- const fileStream = await this.bot.getFileStream(fileId);
173
+ return withRetry(async () => {
174
+ const fileStream = await this.bot.getFileStream(fileId);
127
175
 
128
- // Collect chunks into buffer
129
- const chunks = [];
130
- for await (const chunk of fileStream) {
131
- chunks.push(chunk);
132
- }
176
+ const chunks = [];
177
+ for await (const chunk of fileStream) {
178
+ chunks.push(chunk);
179
+ }
133
180
 
134
- return Buffer.concat(chunks);
181
+ return Buffer.concat(chunks);
182
+ }, `Download ${fileId.substring(0, 12)}...`);
135
183
  }
136
184
 
137
185
  /**
@@ -3,6 +3,13 @@
3
3
  */
4
4
 
5
5
  import chalk from 'chalk';
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
6
13
 
7
14
  export const LOGO = `
8
15
  ████████╗ █████╗ ███████╗
@@ -14,7 +21,7 @@ export const LOGO = `
14
21
  `;
15
22
 
16
23
  export const TAGLINE = 'Telegram as Storage';
17
- export const VERSION = '1.0.0';
24
+ export const VERSION = pkg.version;
18
25
 
19
26
  /**
20
27
  * Print the TAS banner
@@ -57,10 +64,10 @@ export function warn(msg) {
57
64
  * Format file size
58
65
  */
59
66
  export function formatSize(bytes) {
60
- if (bytes === 0) return '0 B';
67
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
61
68
  const k = 1024;
62
69
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
63
- const i = Math.floor(Math.log(bytes) / Math.log(k));
70
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
64
71
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
65
72
  }
66
73
 
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * File chunking utilities for large files
3
- * WhatsApp document limit is ~2GB, we use 1.9GB chunks to be safe
3
+ * Telegram Bot API limit is 50 MB for bot uploads (sendDocument).
4
+ * We use 49 MB to leave room for the 64-byte WAS1 header.
5
+ * See: https://core.telegram.org/bots/api#senddocument
4
6
  */
5
7
 
6
- const MAX_CHUNK_SIZE = 1.9 * 1024 * 1024 * 1024; // 1.9 GB
8
+ const MAX_CHUNK_SIZE = 49 * 1024 * 1024; // 49 MB — Telegram Bot API safe limit
7
9
 
8
10
  export class Chunker {
9
11
  /**
@@ -39,14 +39,13 @@ export async function getPassword(passwordOption, allowCache = true) {
39
39
  }
40
40
 
41
41
  /**
42
- * Verify password against config
42
+ * Verify password against config (supports both legacy and new hash formats)
43
43
  * @param {string} password - Password to verify
44
44
  * @param {Object} config - Config object with passwordHash
45
45
  * @returns {boolean}
46
46
  */
47
47
  export function verifyPassword(password, config) {
48
- const encryptor = new Encryptor(password);
49
- return encryptor.getPasswordHash() === config.passwordHash;
48
+ return Encryptor.verifyPasswordHash(password, config.passwordHash);
50
49
  }
51
50
 
52
51
  /**
@@ -62,11 +61,13 @@ export function validateConfig(config) {
62
61
  return { valid: false, errors };
63
62
  }
64
63
 
65
- if (!config.botToken || typeof config.botToken !== 'string') {
66
- errors.push('Missing or invalid botToken');
64
+ // v2: encrypted token, v1: plaintext token
65
+ const hasToken = config.encryptedBotToken || config.botToken;
66
+ if (!hasToken) {
67
+ errors.push('Missing bot token (botToken or encryptedBotToken)');
67
68
  }
68
69
 
69
- if (!config.botToken?.includes(':')) {
70
+ if (config.botToken && !config.botToken.includes(':')) {
70
71
  errors.push('Invalid bot token format (should contain :)');
71
72
  }
72
73
 
@@ -84,6 +85,29 @@ export function validateConfig(config) {
84
85
  };
85
86
  }
86
87
 
88
+ /**
89
+ * Decrypt bot token from config using password
90
+ * Supports both v1 (plaintext) and v2 (encrypted) configs
91
+ * @param {Object} config - Config object
92
+ * @param {string} password - User's password
93
+ * @returns {string} - Decrypted bot token
94
+ */
95
+ export function decryptBotToken(config, password) {
96
+ // v1: plaintext token (backward compatibility)
97
+ if (config.botToken) {
98
+ return config.botToken;
99
+ }
100
+
101
+ // v2: encrypted token
102
+ if (config.encryptedBotToken) {
103
+ const encryptor = new Encryptor(password);
104
+ const encryptedBuffer = Buffer.from(config.encryptedBotToken, 'base64');
105
+ return encryptor.decrypt(encryptedBuffer).toString('utf-8');
106
+ }
107
+
108
+ throw new Error('No bot token found in config');
109
+ }
110
+
87
111
  /**
88
112
  * Load and validate config
89
113
  * @param {string} dataDir - Data directory path
@@ -143,3 +167,17 @@ export async function getAndVerifyPassword(passwordOption, dataDir) {
143
167
 
144
168
  return password;
145
169
  }
170
+
171
+ /**
172
+ * Resolve config by decrypting bot token if needed.
173
+ * Returns a config object with a guaranteed plaintext `botToken` field.
174
+ * @param {Object} config - Raw config from disk
175
+ * @param {string} password - Verified password
176
+ * @returns {Object} - Config with decrypted botToken
177
+ */
178
+ export function resolveConfig(config, password) {
179
+ return {
180
+ ...config,
181
+ botToken: decryptBotToken(config, password)
182
+ };
183
+ }
@@ -76,10 +76,10 @@ export class ProgressBar {
76
76
  * Format bytes to human readable
77
77
  */
78
78
  formatBytes(bytes) {
79
- if (bytes === 0) return '0 B';
79
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
80
80
  const k = 1024;
81
- const sizes = ['B', 'KB', 'MB', 'GB'];
82
- const i = Math.floor(Math.log(bytes) / Math.log(k));
81
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
82
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
83
83
  return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
84
84
  }
85
85