@nightowne/tas-cli 3.0.1 → 3.0.3

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to TAS (Telegram as Storage) will be documented in this file.
4
4
 
5
+ ## [3.0.3] - 2026-09-25
6
+
7
+ ### Fixed — desktop and SMB mount interoperability (#3)
8
+ - **Complete directory surface** — FUSE now implements `statfs`, `access`, `opendir`, `releasedir`, flush/sync callbacks, stable inode metadata, and session extended attributes used by Linux file managers and Samba.
9
+ - **Secure shared mounts** — `tas mount <path> --allow-other` opts into access by other local users and services. On Linux TAS requires `user_allow_other` in `/etc/fuse.conf`; private single-user mounts remain the default.
10
+ - **Usable capacity reporting** — desktop clients receive real local staging-disk statistics rather than an empty or unsupported filesystem response. This is not presented as a Telegram quota.
11
+ - **Version consistency** — the CLI banner now matches the package version.
12
+
13
+ ## [3.0.2] - 2026-09-25
14
+
15
+ ### Fixed — Apple Silicon install recovery
16
+ - **Optional native source recovery** — if the legacy optional addon fails before TAS can patch it on arm64, postinstall restores only its source without lifecycle scripts, then rebuilds it against the user's installed macFUSE library. Failed rebuilds still leave every non-mount TAS command usable.
17
+
5
18
  ## [3.0.1] - 2026-09-25
6
19
 
7
20
  ### Fixed — current macFUSE mount support (#4)
package/FAQ.md CHANGED
@@ -31,3 +31,7 @@ No. Each chunk has one owning bot. TAS records that bot so reads and deletes rou
31
31
  ## Does mount work on macOS?
32
32
 
33
33
  Yes with current macFUSE, Xcode Command Line Tools, and a locally rebuilt native addon. TAS never installs or replaces macFUSE; its install hook selects the system `libfuse` and compiles the optional addon for the active CPU. Run `tas doctor` before use. If the real mount/readdir/unmount smoke test does not pass, do not mount data.
34
+
35
+ ## Why can I list a mount locally but not open its Samba share?
36
+
37
+ FUSE mounts are private to the mounting user by default. Add `user_allow_other` to `/etc/fuse.conf`, then mount with `tas mount /mnt/tg-drive --allow-other`. TAS keeps this opt-in because it grants other local users and services access subject to Unix permissions. Version 3.0.3 also implements the filesystem statistics, directory handles, sync operations, and extended attributes expected by desktop and SMB clients.
package/QUICKSTART.md CHANGED
@@ -45,3 +45,5 @@ Multi-bot mode distributes chunks. It is not redundancy, quota assurance, ban pr
45
45
  ## Mount support
46
46
 
47
47
  `tas mount` needs a native FUSE runtime. On Linux install `fuse` and `libfuse-dev`. On macOS install current macFUSE and Xcode Command Line Tools before installing TAS; TAS rebuilds its optional native addon against the installed macFUSE library. Run `tas doctor` first and mount only when its real mount/readdir/unmount smoke test passes.
48
+
49
+ For Samba or another service account, add `user_allow_other` to `/etc/fuse.conf` and mount with `tas mount /mnt/tg-drive --allow-other`. Shared access is opt-in because it exposes the mount to other local users subject to Unix permissions.
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  <a href="https://github.com/ixchio/tas/network/members"><img src="https://img.shields.io/github/forks/ixchio/tas?style=social" alt="GitHub Forks"></a>
19
19
  <img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen?logo=node.js" alt="Node.js >= 18">
20
20
  <img src="https://img.shields.io/badge/encryption-AES--256--GCM-blueviolet?logo=shield" alt="AES-256-GCM">
21
- <img src="https://img.shields.io/badge/tests-101%20passing-success" alt="101 Tests Passing">
21
+ <img src="https://img.shields.io/badge/tests-104%20passing-success" alt="104 Tests Passing">
22
22
  </p>
23
23
 
24
24
  <p align="center">
@@ -115,6 +115,15 @@ tas unmount ~/cloud # Clean unmount when done
115
115
 
116
116
  > **Native FUSE is verified, not assumed:** Linux needs `fuse`/`libfuse-dev`. macOS uses current macFUSE and rebuilds the optional native addon against the installed system library on first install. Install Xcode Command Line Tools before TAS, then run `tas doctor`; mount is available only when its real mount → readdir → unmount smoke test passes.
117
117
 
118
+ Desktop file managers work with the normal private mount. Samba and other services run under a different local identity, so shared access must be enabled explicitly:
119
+
120
+ ```bash
121
+ # Add this exact line to /etc/fuse.conf first: user_allow_other
122
+ tas mount /mnt/tg-drive --allow-other
123
+ ```
124
+
125
+ `--allow-other` exposes the mount to other local users subject to Unix permissions. Do not enable it on an untrusted multi-user host.
126
+
118
127
  ---
119
128
 
120
129
  ### 🔄 Auto-Sync Folders (Dropbox-style)
@@ -353,6 +362,7 @@ tas bot add|list|enable|disable|remove # 🤖 Manage experimental bot pool
353
362
  ```bash
354
363
  # FUSE Mount (Linux/libfuse or macOS/current macFUSE)
355
364
  tas mount <path> # Mount Telegram storage as a local folder
365
+ tas mount <path> --allow-other # Opt in to Samba/service access
356
366
  tas unmount <path> # Clean unmount
357
367
 
358
368
  # Dropbox-style Folder Sync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Experimental local-first encrypted file transport over Telegram bots with resumable chunks, index recovery, and FUSE mount",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -816,6 +816,7 @@ program
816
816
  .command('mount <mountpoint>')
817
817
  .description('🔥 Mount Telegram storage as a local folder (Linux FUSE or current macFUSE)')
818
818
  .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
819
+ .option('--allow-other', 'Allow other local users and services such as Samba to access the mount')
819
820
  .action(async (mountpoint, options) => {
820
821
  console.log(chalk.cyan('\n🗂️ Mounting Telegram as filesystem...\n'));
821
822
 
@@ -836,7 +837,8 @@ program
836
837
  dataDir: DATA_DIR,
837
838
  password,
838
839
  config,
839
- mountPoint: absMount
840
+ mountPoint: absMount,
841
+ allowOther: options.allowOther
840
842
  });
841
843
 
842
844
  await tfs.initialize();
@@ -851,6 +853,9 @@ program
851
853
  console.log(chalk.dim(` cat ${absMount}/file.txt # Read`));
852
854
  console.log(chalk.dim(` rm ${absMount}/file.pdf # Delete`));
853
855
  console.log();
856
+ if (options.allowOther) {
857
+ console.log(chalk.yellow('Shared access is enabled; Unix file permissions still apply'));
858
+ }
854
859
  console.log(chalk.yellow('Press Ctrl+C to unmount'));
855
860
 
856
861
  // Handle graceful shutdown
@@ -873,6 +878,7 @@ program
873
878
  console.log(chalk.dim(' Ubuntu/Debian: sudo apt install fuse libfuse-dev'));
874
879
  console.log(chalk.dim(' Fedora: sudo dnf install fuse fuse-devel'));
875
880
  console.log(chalk.dim(' macOS: install current macFUSE and Xcode Command Line Tools, then reinstall TAS\n'));
881
+ console.log(chalk.dim(' Samba: enable user_allow_other in /etc/fuse.conf and mount with --allow-other\n'));
876
882
  process.exit(1);
877
883
  }
878
884
  });
@@ -84,6 +84,23 @@ function getDarwinLibraryDirectory (packageRoot) {
84
84
  }
85
85
  }
86
86
 
87
+ function installOptionalFuseNative (packageRoot) {
88
+ const npmCli = process.env.npm_execpath
89
+ if (!npmCli) return { ok: false, reason: 'npm did not provide its install executable' }
90
+
91
+ const install = childProcess.spawnSync(
92
+ process.execPath,
93
+ [npmCli, 'install', 'fuse-native@2.2.6', '--no-save', '--ignore-scripts', '--no-package-lock'],
94
+ {
95
+ cwd: packageRoot,
96
+ stdio: 'inherit',
97
+ env: { ...process.env, npm_config_ignore_scripts: 'true' }
98
+ }
99
+ )
100
+ if (install.status !== 0) return { ok: false, reason: 'could not restore optional fuse-native source' }
101
+ return { ok: true }
102
+ }
103
+
87
104
  function validateMacFuseNativeBinding ({
88
105
  packageRoot = path.resolve(__dirname, '..', '..'),
89
106
  exists = fs.existsSync,
@@ -184,8 +201,20 @@ function installMacFuseAdapter ({
184
201
  return { skipped: true, ...installation }
185
202
  }
186
203
 
187
- const fuseNativeDir = getFuseNativeDirectory(packageRoot)
188
- const darwinLibraryDir = getDarwinLibraryDirectory(packageRoot)
204
+ let fuseNativeDir = getFuseNativeDirectory(packageRoot)
205
+ let darwinLibraryDir = getDarwinLibraryDirectory(packageRoot)
206
+ if (!fuseNativeDir || !darwinLibraryDir) {
207
+ // The legacy optional addon can fail to build on arm64 before our root
208
+ // postinstall runs. Restore its source without lifecycle scripts, then
209
+ // patch and rebuild it against the system macFUSE library below.
210
+ const restored = installOptionalFuseNative(packageRoot)
211
+ if (!restored.ok) {
212
+ log.warn(`[tas] macOS FUSE was not built: ${restored.reason}`)
213
+ return { ready: false, reason: restored.reason }
214
+ }
215
+ fuseNativeDir = getFuseNativeDirectory(packageRoot)
216
+ darwinLibraryDir = getDarwinLibraryDirectory(packageRoot)
217
+ }
189
218
  if (!fuseNativeDir || !darwinLibraryDir) {
190
219
  const reason = 'optional fuse-native dependencies are not installed'
191
220
  log.warn(`[tas] macOS FUSE was not built: ${reason}`)
@@ -228,6 +257,7 @@ module.exports = {
228
257
  MACFUSE_BUNDLE,
229
258
  findMacFuseInstallation,
230
259
  getFuseNativeDirectory,
260
+ installOptionalFuseNative,
231
261
  validateMacFuseNativeBinding,
232
262
  installMacFuseAdapter
233
263
  }
package/src/fuse/mount.js CHANGED
@@ -45,6 +45,22 @@ function withTimeout(promise, ms, label) {
45
45
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
46
46
  }
47
47
 
48
+ function pathInode(logicalPath) {
49
+ if (!logicalPath) return 1;
50
+ let hash = 2166136261;
51
+ for (let index = 0; index < logicalPath.length; index++) {
52
+ hash ^= logicalPath.charCodeAt(index);
53
+ hash = Math.imul(hash, 16777619);
54
+ }
55
+ return (hash >>> 0) || 1;
56
+ }
57
+
58
+ function fuseStatNumber(value, fallback = 0) {
59
+ const number = Number(value);
60
+ if (!Number.isFinite(number) || number < 0) return fallback;
61
+ return Math.min(Math.floor(number), 0xffffffff);
62
+ }
63
+
48
64
  /** Verify the native FUSE stack, not merely that the JS module imports. */
49
65
  export async function checkFuseRuntime() {
50
66
  if (process.platform === 'darwin') {
@@ -113,7 +129,9 @@ export class TelegramFS {
113
129
  this.password = options.password;
114
130
  this.config = options.config;
115
131
  this.mountPoint = options.mountPoint;
132
+ this.allowOther = options.allowOther === true;
116
133
  this.backupManifest = options.backupManifest || backupRemoteManifest;
134
+ this.mountStartedAt = new Date();
117
135
 
118
136
  this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
119
137
  this.db.init();
@@ -131,6 +149,7 @@ export class TelegramFS {
131
149
  this.fileByLogicalPath = new Map();
132
150
  this.implicitDirs = new Set(['']);
133
151
  this.childrenByDir = new Map();
152
+ this.xattrs = new Map();
134
153
  this._refreshPathIndex();
135
154
  }
136
155
 
@@ -185,6 +204,10 @@ export class TelegramFS {
185
204
  return this.fileByLogicalPath.get(logicalPath);
186
205
  }
187
206
 
207
+ _exists(logicalPath) {
208
+ return this._isDirectory(logicalPath) || this.writeBuffers.has(logicalPath) || Boolean(this._file(logicalPath));
209
+ }
210
+
188
211
  _newWritePath(logicalPath) {
189
212
  const dir = path.join(this.dataDir, 'fuse-writes');
190
213
  fs.mkdirSync(dir, { recursive: true });
@@ -220,13 +243,17 @@ export class TelegramFS {
220
243
 
221
244
  if (this._isDirectory(logical)) {
222
245
  return cb(0, {
223
- mtime: new Date(),
224
- atime: new Date(),
225
- ctime: new Date(),
246
+ mtime: this.mountStartedAt,
247
+ atime: this.mountStartedAt,
248
+ ctime: this.mountStartedAt,
226
249
  size: 4096,
227
250
  mode: 0o40755, // directory
228
251
  uid: process.getuid?.() || 0,
229
- gid: process.getgid?.() || 0
252
+ gid: process.getgid?.() || 0,
253
+ nlink: 2,
254
+ ino: pathInode(logical),
255
+ blksize: 4096,
256
+ blocks: 1
230
257
  });
231
258
  }
232
259
 
@@ -241,7 +268,10 @@ export class TelegramFS {
241
268
  size: stats.size,
242
269
  mode: 0o100644, // regular file
243
270
  uid: process.getuid?.() || 0,
244
- gid: process.getgid?.() || 0
271
+ gid: process.getgid?.() || 0,
272
+ ino: pathInode(logical),
273
+ blksize: 4096,
274
+ blocks: Math.ceil(stats.size / 512)
245
275
  });
246
276
  }
247
277
 
@@ -259,7 +289,10 @@ export class TelegramFS {
259
289
  size: file.original_size,
260
290
  mode: 0o100644, // regular file
261
291
  uid: process.getuid?.() || 0,
262
- gid: process.getgid?.() || 0
292
+ gid: process.getgid?.() || 0,
293
+ ino: pathInode(logical),
294
+ blksize: 4096,
295
+ blocks: Math.ceil(file.original_size / 512)
263
296
  });
264
297
  }
265
298
 
@@ -278,6 +311,109 @@ export class TelegramFS {
278
311
  return cb(0, [...names].sort((a, b) => a.localeCompare(b)));
279
312
  }
280
313
 
314
+ /** Report the local staging disk because Telegram exposes no storage quota. */
315
+ statfs(filepath, cb) {
316
+ try {
317
+ const stats = typeof fs.statfsSync === 'function' ? fs.statfsSync(this.dataDir) : null;
318
+ const fileCount = this.filePaths.size + this.virtualDirs.size + this.writeBuffers.size + 1;
319
+ return cb(0, {
320
+ bsize: fuseStatNumber(stats?.bsize, 4096),
321
+ frsize: fuseStatNumber(stats?.bsize, 4096),
322
+ blocks: fuseStatNumber(stats?.blocks, 1),
323
+ bfree: fuseStatNumber(stats?.bfree, 1),
324
+ bavail: fuseStatNumber(stats?.bavail, 1),
325
+ files: fuseStatNumber(stats?.files, fileCount),
326
+ ffree: fuseStatNumber(stats?.ffree, 0),
327
+ favail: fuseStatNumber(stats?.ffree, 0),
328
+ fsid: 0,
329
+ flag: 0,
330
+ namemax: 255
331
+ });
332
+ } catch (error) {
333
+ return cb(Fuse.EIO);
334
+ }
335
+ }
336
+
337
+ access(filepath, mode, cb) {
338
+ let logical;
339
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
340
+ return cb(this._exists(logical) ? 0 : Fuse.ENOENT);
341
+ }
342
+
343
+ opendir(filepath, flags, cb) {
344
+ let logical;
345
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
346
+ return this._isDirectory(logical) ? cb(0, pathInode(logical)) : cb(Fuse.ENOTDIR);
347
+ }
348
+
349
+ releasedir(filepath, fd, cb) {
350
+ return cb(0);
351
+ }
352
+
353
+ flush(filepath, fd, cb) {
354
+ return cb(0);
355
+ }
356
+
357
+ fsync(filepath, datasync, fd, cb) {
358
+ return cb(0);
359
+ }
360
+
361
+ fsyncdir(filepath, datasync, fd, cb) {
362
+ return cb(0);
363
+ }
364
+
365
+ utimens(filepath, atime, mtime, cb) {
366
+ let logical;
367
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
368
+ return cb(this._exists(logical) ? 0 : Fuse.ENOENT);
369
+ }
370
+
371
+ chmod(filepath, mode, cb) {
372
+ let logical;
373
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
374
+ return cb(this._exists(logical) ? 0 : Fuse.ENOENT);
375
+ }
376
+
377
+ chown(filepath, uid, gid, cb) {
378
+ let logical;
379
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
380
+ return cb(this._exists(logical) ? 0 : Fuse.ENOENT);
381
+ }
382
+
383
+ getxattr(filepath, name, position, cb) {
384
+ let logical;
385
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
386
+ if (!this._exists(logical)) return cb(Fuse.ENOENT);
387
+ const value = this.xattrs.get(logical)?.get(name);
388
+ return value ? cb(0, Buffer.from(value)) : cb(Fuse.ENODATA);
389
+ }
390
+
391
+ setxattr(filepath, name, value, position, flags, cb) {
392
+ let logical;
393
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
394
+ if (!this._exists(logical)) return cb(Fuse.ENOENT);
395
+ if (!this.xattrs.has(logical)) this.xattrs.set(logical, new Map());
396
+ this.xattrs.get(logical).set(name, Buffer.from(value));
397
+ return cb(0);
398
+ }
399
+
400
+ listxattr(filepath, cb) {
401
+ let logical;
402
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
403
+ if (!this._exists(logical)) return cb(Fuse.ENOENT);
404
+ return cb(0, [...(this.xattrs.get(logical)?.keys() || [])]);
405
+ }
406
+
407
+ removexattr(filepath, name, cb) {
408
+ let logical;
409
+ try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
410
+ if (!this._exists(logical)) return cb(Fuse.ENOENT);
411
+ const attributes = this.xattrs.get(logical);
412
+ if (!attributes?.delete(name)) return cb(Fuse.ENODATA);
413
+ if (attributes.size === 0) this.xattrs.delete(logical);
414
+ return cb(0);
415
+ }
416
+
281
417
  /**
282
418
  * Open a file (just validates it exists)
283
419
  */
@@ -398,6 +534,7 @@ export class TelegramFS {
398
534
 
399
535
  // Invalidate cache
400
536
  this.invalidateCache(logical);
537
+ this.xattrs.delete(logical);
401
538
 
402
539
  return cb(0);
403
540
  } catch (err) {
@@ -517,6 +654,11 @@ export class TelegramFS {
517
654
  fileCache.delete(oldName);
518
655
  fileCache.set(newName, cached);
519
656
  }
657
+ const attributes = this.xattrs.get(oldName);
658
+ if (attributes) {
659
+ this.xattrs.delete(oldName);
660
+ this.xattrs.set(newName, attributes);
661
+ }
520
662
 
521
663
  return cb(0);
522
664
  }
@@ -539,6 +681,7 @@ export class TelegramFS {
539
681
  return cb(Fuse.ENOTEMPTY || Fuse.EINVAL);
540
682
  }
541
683
  this.virtualDirs.delete(logical);
684
+ this.xattrs.delete(logical);
542
685
  return cb(0);
543
686
  }
544
687
 
@@ -679,10 +822,32 @@ export class TelegramFS {
679
822
  }
680
823
  }
681
824
 
825
+ _mountOptions() {
826
+ return {
827
+ debug: false,
828
+ force: true,
829
+ mkdir: true,
830
+ allowOther: this.allowOther,
831
+ defaultPermissions: true,
832
+ fsname: 'tas',
833
+ subtype: 'tas'
834
+ };
835
+ }
836
+
682
837
  /**
683
838
  * Mount the filesystem
684
839
  */
685
840
  mount() {
841
+ if (this.allowOther && process.platform === 'linux' && (process.getuid?.() || 0) !== 0) {
842
+ let fuseConfig = '';
843
+ try { fuseConfig = fs.readFileSync('/etc/fuse.conf', 'utf8'); } catch { }
844
+ if (!/^\s*user_allow_other\s*$/m.test(fuseConfig)) {
845
+ throw new Error(
846
+ '--allow-other requires user_allow_other on its own line in /etc/fuse.conf'
847
+ );
848
+ }
849
+ }
850
+
686
851
  // Ensure mount point exists
687
852
  if (!fs.existsSync(this.mountPoint)) {
688
853
  fs.mkdirSync(this.mountPoint, { recursive: true });
@@ -691,11 +856,25 @@ export class TelegramFS {
691
856
  const ops = {
692
857
  getattr: this.getattr.bind(this),
693
858
  readdir: this.readdir.bind(this),
859
+ statfs: this.statfs.bind(this),
860
+ access: this.access.bind(this),
861
+ opendir: this.opendir.bind(this),
862
+ releasedir: this.releasedir.bind(this),
694
863
  open: this.open.bind(this),
695
864
  read: this.read.bind(this),
696
865
  write: this.write.bind(this),
697
866
  create: this.create.bind(this),
698
867
  release: this.release.bind(this),
868
+ flush: this.flush.bind(this),
869
+ fsync: this.fsync.bind(this),
870
+ fsyncdir: this.fsyncdir.bind(this),
871
+ utimens: this.utimens.bind(this),
872
+ chmod: this.chmod.bind(this),
873
+ chown: this.chown.bind(this),
874
+ getxattr: this.getxattr.bind(this),
875
+ setxattr: this.setxattr.bind(this),
876
+ listxattr: this.listxattr.bind(this),
877
+ removexattr: this.removexattr.bind(this),
699
878
  unlink: this.unlink.bind(this),
700
879
  rename: this.rename.bind(this),
701
880
  mkdir: this.mkdir.bind(this),
@@ -704,11 +883,7 @@ export class TelegramFS {
704
883
  ftruncate: this.ftruncate.bind(this)
705
884
  };
706
885
 
707
- this.fuse = new Fuse(this.mountPoint, ops, {
708
- debug: false,
709
- force: true,
710
- mkdir: true
711
- });
886
+ this.fuse = new Fuse(this.mountPoint, ops, this._mountOptions());
712
887
 
713
888
  return new Promise((resolve, reject) => {
714
889
  this.fuse.mount((err) => {
@@ -14,7 +14,7 @@ export const LOGO = `
14
14
  `;
15
15
 
16
16
  export const TAGLINE = 'Telegram as Storage';
17
- export const VERSION = '3.0.0';
17
+ export const VERSION = '3.0.3';
18
18
 
19
19
  /**
20
20
  * Print the TAS banner