@rljson/fs-agent 0.0.14 → 0.0.15

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.
@@ -107,6 +107,12 @@ export interface TimeoutConfig {
107
107
  }
108
108
  /** Filename for sync error log written to the sync folder */
109
109
  export declare const SYNC_ERROR_FILE = ".sync-errors.log";
110
+ /**
111
+ * Filename prefix for the staging files used by atomic writes. The scanner
112
+ * ignores anything starting with this so the transient temp + rename never
113
+ * pollutes the tree or churns the watcher.
114
+ */
115
+ export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
110
116
  /**
111
117
  * Orchestrates filesystem operations with tree structures and blob storage
112
118
  */
@@ -167,6 +173,31 @@ export declare class FsAgent {
167
173
  * @returns A message string.
168
174
  */
169
175
  private static _errMessage;
176
+ /**
177
+ * Retries an async operation up to `attempts` times with exponential backoff
178
+ * (each delay doubles from `baseDelayMs`). For transient failures — a file
179
+ * briefly locked by antivirus or a save-and-rename editor, a peer briefly
180
+ * unreachable. Non-final failures are logged once at warn level so retry
181
+ * pressure is visible without log-spam.
182
+ * @param fn - The operation to run
183
+ * @param attempts - Maximum number of attempts
184
+ * @param baseDelayMs - Initial backoff delay (doubles each retry)
185
+ * @param label - Human-readable label for log messages
186
+ * @returns The operation's resolved value
187
+ */
188
+ private static _withRetry;
189
+ /**
190
+ * Atomically writes a file: stages the content in a sibling `.<rand>.tmp`,
191
+ * then renames over the target. The rename is atomic, so a crash mid-write
192
+ * leaves only the temp behind — never a half-written target file. (We do not
193
+ * `fsync` the temp: it adds significant per-file latency under bursty
194
+ * restores, and durability-on-power-loss is secondary here since the content
195
+ * is replicated and re-synced.) The random suffix keeps concurrent restores
196
+ * of the same path from trampling each other.
197
+ * @param filePath - Destination path
198
+ * @param content - Bytes to write
199
+ */
200
+ private static _atomicWriteFile;
170
201
  /**
171
202
  * Wraps a promise with a timeout.
172
203
  * Rejects with a descriptive error if the promise does not settle
@@ -217,6 +248,14 @@ export declare class FsAgent {
217
248
  * @param options - Restore options
218
249
  */
219
250
  restore(tree: FsTree, targetPath?: string, options?: RestoreOptions): Promise<void>;
251
+ /**
252
+ * Recursively collects the absolute paths of all files under `currentDir`.
253
+ * Used to snapshot the pre-restore file set for prune race-protection.
254
+ * @param currentDir - Directory to walk
255
+ * @param out - Accumulator set (created if omitted)
256
+ * @returns The set of absolute file paths
257
+ */
258
+ private _collectAllFiles;
220
259
  /**
221
260
  * Recursively restores a tree node and its children
222
261
  * @param treeHash - Hash of the tree node to restore
@@ -284,10 +323,13 @@ export declare class FsAgent {
284
323
  */
285
324
  private _collectExpectedPaths;
286
325
  /**
287
- * Remove files/dirs not present in the expected sets
326
+ * Remove files/dirs not present in the expected sets, preserving any file
327
+ * that appeared *during* the restore (not in `preRestore`) — a fresh user
328
+ * write that must not be clobbered.
288
329
  * @param currentDir - Directory currently being inspected
289
330
  * @param expectedDirs - Allowed directory paths
290
331
  * @param expectedFiles - Allowed file paths
332
+ * @param preRestore - Files present before the restore (prune candidates)
291
333
  */
292
334
  private _pruneExtraneous;
293
335
  /**
package/dist/fs-agent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BsMem } from "@rljson/bs";
2
2
  import { Route, createTreesTableCfg } from "@rljson/rljson";
3
3
  import { watch, appendFileSync } from "fs";
4
- import { stat, readFile, mkdir, writeFile, readdir, utimes, rm } from "fs/promises";
4
+ import { stat, readFile, mkdir, writeFile, readdir, rename, unlink, utimes, rm } from "fs/promises";
5
5
  import { dirname, join } from "path";
6
6
  import { hip } from "@rljson/hash";
7
7
  import { Db } from "@rljson/db";
@@ -422,6 +422,10 @@ class FsScanner {
422
422
  _bs;
423
423
  _paused = false;
424
424
  _missedChangesDuringPause = false;
425
+ /** Periodic full-rescan timer that catches events the native watcher drops. */
426
+ _safetyTimer = null;
427
+ /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
428
+ _stopRequested = false;
425
429
  constructor(rootPath, options = {}) {
426
430
  this._rootPath = rootPath;
427
431
  this._options = {
@@ -591,18 +595,102 @@ class FsScanner {
591
595
  if (!this._tree) {
592
596
  await this.scan();
593
597
  }
594
- this._watcher = watch(
595
- this._rootPath,
596
- { recursive: true },
597
- /* v8 ignore next -- @preserve */
598
- async (eventType, filename) => {
599
- if (!filename) return;
600
- if (this._shouldIgnore(filename)) {
601
- return;
598
+ const onEvent = async (eventType, filename) => {
599
+ if (!filename) return;
600
+ if (this._shouldIgnore(filename)) {
601
+ return;
602
+ }
603
+ await this._handleFileChange(eventType, filename);
604
+ };
605
+ const onError = (err) => {
606
+ console.warn(
607
+ `[fs-scanner] watcher error: ${FsScanner._errMessage(err)} — reinstalling`
608
+ );
609
+ try {
610
+ this._watcher?.close();
611
+ } catch {
612
+ }
613
+ this._watcher = null;
614
+ setTimeout(() => {
615
+ if (this._stopRequested) return;
616
+ try {
617
+ this._watcher = watch(this._rootPath, { recursive: true }, onEvent);
618
+ if (FsScanner._isWindows) this._watcher.on("error", onError);
619
+ } catch (e) {
620
+ console.warn(
621
+ `[fs-scanner] watcher reinstall failed: ${FsScanner._errMessage(e)}`
622
+ );
602
623
  }
603
- await this._handleFileChange(eventType, filename);
624
+ }, 500);
625
+ };
626
+ this._stopRequested = false;
627
+ this._watcher = watch(this._rootPath, { recursive: true }, onEvent);
628
+ if (FsScanner._isWindows) this._watcher.on("error", onError);
629
+ if (!this._safetyTimer) {
630
+ this._safetyTimer = setInterval(() => {
631
+ void this._runSafetyRescan();
632
+ }, 3e4);
633
+ this._safetyTimer.unref?.();
634
+ }
635
+ }
636
+ /**
637
+ * One safety-rescan pass: rescans the tree and, if its content differs from
638
+ * the previous scan (the native watcher dropped an event), emits a sync
639
+ * notification so syncToDb reconciles the drift. Paused/stopped scanners and
640
+ * scan failures are no-ops.
641
+ */
642
+ async _runSafetyRescan() {
643
+ if (this._paused || this._stopRequested) return;
644
+ const prevKey = this._tree ? this._safetyContentKey(this._tree) : null;
645
+ try {
646
+ await this.scan();
647
+ } catch (err) {
648
+ console.warn(
649
+ `[fs-scanner] safety rescan failed: ${FsScanner._errMessage(err)}`
650
+ );
651
+ return;
652
+ }
653
+ if (this._paused || this._stopRequested) return;
654
+ const nextKey = this._tree ? this._safetyContentKey(this._tree) : null;
655
+ if (prevKey !== nextKey) {
656
+ console.warn(
657
+ `[fs-scanner] safety rescan detected drift on ${this._rootPath} — notifying`
658
+ );
659
+ await this._notifyChange({ type: "safety-rescan", path: "." });
660
+ }
661
+ }
662
+ /**
663
+ * Path+blobId content fingerprint used by the safety rescan to detect drift
664
+ * the native watcher missed (mtime-independent, same idea as the agent's
665
+ * content key but local to the scanner).
666
+ * @param tree - The tree to fingerprint
667
+ * @returns A stable content key
668
+ */
669
+ _safetyContentKey(tree) {
670
+ const parts = [];
671
+ for (const [, node] of tree.trees) {
672
+ const meta = node.meta;
673
+ if (!meta) continue;
674
+ if (meta.type === "file") {
675
+ parts.push(`${meta.relativePath}:${meta.blobId ?? ""}`);
676
+ } else if (meta.type === "directory" && meta.relativePath !== ".") {
677
+ parts.push(`d:${meta.relativePath}`);
604
678
  }
605
- );
679
+ }
680
+ parts.sort();
681
+ return parts.join("\n");
682
+ }
683
+ /**
684
+ * Extracts a readable message from a thrown value.
685
+ * @param err - The caught value
686
+ * @returns A message string
687
+ */
688
+ static _errMessage(err) {
689
+ return err instanceof Error ? err.message : String(err);
690
+ }
691
+ /** Whether the host is Windows — gates Windows-specific watcher hardening. */
692
+ static get _isWindows() {
693
+ return process.platform === "win32";
606
694
  }
607
695
  async _handleFileChange(_eventType, filename) {
608
696
  if (this._paused) {
@@ -610,23 +698,35 @@ class FsScanner {
610
698
  return;
611
699
  }
612
700
  const relativePath = filename.replace(/\\/g, "/");
701
+ const fullPath = join(this._rootPath, filename);
702
+ let exists = false;
703
+ if (FsScanner._isWindows) {
704
+ for (let i = 0; i < 4; i++) {
705
+ try {
706
+ await stat(fullPath);
707
+ exists = true;
708
+ break;
709
+ } catch {
710
+ if (i < 3) await new Promise((r) => setTimeout(r, 80 + i * 80));
711
+ }
712
+ }
713
+ } else {
714
+ try {
715
+ await stat(fullPath);
716
+ exists = true;
717
+ } catch {
718
+ }
719
+ }
613
720
  try {
614
- await stat(join(this._rootPath, filename));
615
- const existingTree = this._findTreeByPath(relativePath);
616
- if (!existingTree) {
721
+ if (exists) {
722
+ const existingTree = this._findTreeByPath(relativePath);
617
723
  await this.scan();
618
724
  await this._notifyChange({
619
- type: "added",
620
- path: relativePath
621
- });
622
- } else {
623
- await this.scan();
624
- await this._notifyChange({
625
- type: "modified",
725
+ type: existingTree ? "modified" : "added",
626
726
  path: relativePath
627
727
  });
728
+ return;
628
729
  }
629
- } catch {
630
730
  let rootExists = false;
631
731
  try {
632
732
  await stat(this._rootPath);
@@ -635,15 +735,10 @@ class FsScanner {
635
735
  this.stopWatch();
636
736
  }
637
737
  if (rootExists) {
638
- try {
639
- await this.scan();
640
- await this._notifyChange({
641
- type: "deleted",
642
- path: relativePath
643
- });
644
- } catch {
645
- }
738
+ await this.scan();
739
+ await this._notifyChange({ type: "deleted", path: relativePath });
646
740
  }
741
+ } catch {
647
742
  }
648
743
  }
649
744
  _findTreeByPath(relativePath) {
@@ -673,6 +768,11 @@ class FsScanner {
673
768
  );
674
769
  }
675
770
  stopWatch() {
771
+ this._stopRequested = true;
772
+ if (this._safetyTimer) {
773
+ clearInterval(this._safetyTimer);
774
+ this._safetyTimer = null;
775
+ }
676
776
  if (this._watcher) {
677
777
  this._watcher.close();
678
778
  this._watcher = null;
@@ -754,6 +854,7 @@ const DEFAULT_TIMEOUTS = {
754
854
  recoveryRetries: 10
755
855
  };
756
856
  const SYNC_ERROR_FILE = ".sync-errors.log";
857
+ const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
757
858
  class FsAgent {
758
859
  _scanner;
759
860
  _adapter;
@@ -785,7 +886,7 @@ class FsAgent {
785
886
  this._resolveConflicts = options.resolveConflicts ?? false;
786
887
  this._scanner = new FsScanner(rootPath, {
787
888
  ...options,
788
- ignore: [...options.ignore || [], SYNC_ERROR_FILE],
889
+ ignore: [...options.ignore || [], SYNC_ERROR_FILE, ATOMIC_TMP_PREFIX],
789
890
  bs: this._bs
790
891
  });
791
892
  this._adapter = new FsBlobAdapter(this._bs);
@@ -852,6 +953,68 @@ ${err.stack}` : String(err);
852
953
  static _errMessage(err) {
853
954
  return err instanceof Error ? err.message : String(err);
854
955
  }
956
+ /**
957
+ * Retries an async operation up to `attempts` times with exponential backoff
958
+ * (each delay doubles from `baseDelayMs`). For transient failures — a file
959
+ * briefly locked by antivirus or a save-and-rename editor, a peer briefly
960
+ * unreachable. Non-final failures are logged once at warn level so retry
961
+ * pressure is visible without log-spam.
962
+ * @param fn - The operation to run
963
+ * @param attempts - Maximum number of attempts
964
+ * @param baseDelayMs - Initial backoff delay (doubles each retry)
965
+ * @param label - Human-readable label for log messages
966
+ * @returns The operation's resolved value
967
+ */
968
+ static async _withRetry(fn, attempts, baseDelayMs, label) {
969
+ let lastErr;
970
+ for (let i = 0; i < attempts; i++) {
971
+ try {
972
+ return await fn();
973
+ } catch (err) {
974
+ lastErr = err;
975
+ if (i === attempts - 1) {
976
+ break;
977
+ }
978
+ const delay = baseDelayMs * Math.pow(2, i);
979
+ console.warn(
980
+ `[FsAgent] ${label} attempt ${i + 1}/${attempts} failed: ${FsAgent._errMessage(err)} — retry in ${delay}ms`
981
+ );
982
+ await new Promise((r) => setTimeout(r, delay));
983
+ }
984
+ }
985
+ throw lastErr;
986
+ }
987
+ /**
988
+ * Atomically writes a file: stages the content in a sibling `.<rand>.tmp`,
989
+ * then renames over the target. The rename is atomic, so a crash mid-write
990
+ * leaves only the temp behind — never a half-written target file. (We do not
991
+ * `fsync` the temp: it adds significant per-file latency under bursty
992
+ * restores, and durability-on-power-loss is secondary here since the content
993
+ * is replicated and re-synced.) The random suffix keeps concurrent restores
994
+ * of the same path from trampling each other.
995
+ * @param filePath - Destination path
996
+ * @param content - Bytes to write
997
+ */
998
+ static async _atomicWriteFile(filePath, content) {
999
+ if (process.platform !== "win32") {
1000
+ await writeFile(filePath, content);
1001
+ return;
1002
+ }
1003
+ const rnd = `${Date.now().toString(36)}-${Math.floor(
1004
+ Math.random() * 1e9
1005
+ ).toString(36)}`;
1006
+ const tmp = join(dirname(filePath), `${ATOMIC_TMP_PREFIX}${rnd}`);
1007
+ try {
1008
+ await writeFile(tmp, content);
1009
+ await rename(tmp, filePath);
1010
+ } catch (err) {
1011
+ try {
1012
+ await unlink(tmp);
1013
+ } catch {
1014
+ }
1015
+ throw err;
1016
+ }
1017
+ }
855
1018
  /**
856
1019
  * Wraps a promise with a timeout.
857
1020
  * Rejects with a descriptive error if the promise does not settle
@@ -891,11 +1054,18 @@ ${err.stack}` : String(err);
891
1054
  if (this._resolveConflicts) {
892
1055
  connector.setPredecessors(predecessorRefs ?? []);
893
1056
  }
894
- if (connector.syncConfig?.requireAck) {
895
- await connector.sendWithAck(ref);
896
- } else {
897
- connector.send(ref);
898
- }
1057
+ await FsAgent._withRetry(
1058
+ async () => {
1059
+ if (connector.syncConfig?.requireAck) {
1060
+ await connector.sendWithAck(ref);
1061
+ } else {
1062
+ connector.send(ref);
1063
+ }
1064
+ },
1065
+ 3,
1066
+ 100,
1067
+ `sendRef(${ref.slice(0, 12)}…)`
1068
+ );
899
1069
  }
900
1070
  /**
901
1071
  * Starts automatic syncing to database
@@ -958,10 +1128,41 @@ ${err.stack}` : String(err);
958
1128
  tree,
959
1129
  target
960
1130
  );
1131
+ const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
961
1132
  await this._restoreTree(tree.rootHash, tree.trees, target);
962
1133
  if (options?.cleanTarget) {
963
- await this._pruneExtraneous(target, expectedDirs, expectedFiles);
1134
+ await this._pruneExtraneous(
1135
+ target,
1136
+ expectedDirs,
1137
+ expectedFiles,
1138
+ preRestore
1139
+ );
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Recursively collects the absolute paths of all files under `currentDir`.
1144
+ * Used to snapshot the pre-restore file set for prune race-protection.
1145
+ * @param currentDir - Directory to walk
1146
+ * @param out - Accumulator set (created if omitted)
1147
+ * @returns The set of absolute file paths
1148
+ */
1149
+ async _collectAllFiles(currentDir, out) {
1150
+ const result = out ?? /* @__PURE__ */ new Set();
1151
+ let entries;
1152
+ try {
1153
+ entries = await readdir(currentDir, { withFileTypes: true });
1154
+ } catch {
1155
+ return result;
1156
+ }
1157
+ for (const entry of entries) {
1158
+ const fullPath = join(currentDir, entry.name);
1159
+ if (entry.isDirectory()) {
1160
+ await this._collectAllFiles(fullPath, result);
1161
+ } else {
1162
+ result.add(fullPath);
1163
+ }
964
1164
  }
1165
+ return result;
965
1166
  }
966
1167
  /**
967
1168
  * Recursively restores a tree node and its children
@@ -995,7 +1196,7 @@ ${err.stack}` : String(err);
995
1196
  );
996
1197
  }
997
1198
  await mkdir(dirname(filePath), { recursive: true });
998
- await writeFile(filePath, fileBlob.content);
1199
+ await FsAgent._atomicWriteFile(filePath, fileBlob.content);
999
1200
  if (meta.mtime) {
1000
1201
  const mtime = new Date(meta.mtime);
1001
1202
  await utimes(filePath, mtime, mtime);
@@ -1189,25 +1390,38 @@ ${err.stack}` : String(err);
1189
1390
  return { expectedDirs, expectedFiles };
1190
1391
  }
1191
1392
  /**
1192
- * Remove files/dirs not present in the expected sets
1393
+ * Remove files/dirs not present in the expected sets, preserving any file
1394
+ * that appeared *during* the restore (not in `preRestore`) — a fresh user
1395
+ * write that must not be clobbered.
1193
1396
  * @param currentDir - Directory currently being inspected
1194
1397
  * @param expectedDirs - Allowed directory paths
1195
1398
  * @param expectedFiles - Allowed file paths
1399
+ * @param preRestore - Files present before the restore (prune candidates)
1196
1400
  */
1197
- async _pruneExtraneous(currentDir, expectedDirs, expectedFiles) {
1198
- const entries = await readdir(currentDir, { withFileTypes: true });
1401
+ async _pruneExtraneous(currentDir, expectedDirs, expectedFiles, preRestore) {
1402
+ let entries;
1403
+ try {
1404
+ entries = await readdir(currentDir, { withFileTypes: true });
1405
+ } catch {
1406
+ return;
1407
+ }
1199
1408
  for (const entry of entries) {
1200
1409
  const fullPath = join(currentDir, entry.name);
1201
1410
  if (entry.isDirectory()) {
1411
+ await this._pruneExtraneous(
1412
+ fullPath,
1413
+ expectedDirs,
1414
+ expectedFiles,
1415
+ preRestore
1416
+ );
1202
1417
  if (!expectedDirs.has(fullPath)) {
1203
- await rm(fullPath, { recursive: true, force: true });
1204
- continue;
1205
- }
1206
- await this._pruneExtraneous(fullPath, expectedDirs, expectedFiles);
1207
- } else {
1208
- if (!expectedFiles.has(fullPath)) {
1209
- await rm(fullPath, { force: true });
1418
+ const remaining = await readdir(fullPath);
1419
+ if (remaining.length === 0) {
1420
+ await rm(fullPath, { recursive: true, force: true });
1421
+ }
1210
1422
  }
1423
+ } else if (!expectedFiles.has(fullPath) && preRestore.has(fullPath)) {
1424
+ await rm(fullPath, { force: true });
1211
1425
  }
1212
1426
  }
1213
1427
  }
@@ -1266,10 +1480,15 @@ ${err.stack}` : String(err);
1266
1480
  treeKey,
1267
1481
  parentRef ? [parentRef] : void 0
1268
1482
  );
1269
- const ref = await FsAgent._withTimeout(
1270
- dbAdapter.storeFsTree(tree, { ...options, previous }),
1271
- this._timeouts.fetchTree,
1272
- `syncToDb → storeFsTree(${treeKey})`
1483
+ const ref = await FsAgent._withRetry(
1484
+ () => FsAgent._withTimeout(
1485
+ dbAdapter.storeFsTree(tree, { ...options, previous }),
1486
+ this._timeouts.fetchTree,
1487
+ `syncToDb → storeFsTree(${treeKey})`
1488
+ ),
1489
+ 3,
1490
+ 200,
1491
+ `syncToDb storeFsTree(${treeKey})`
1273
1492
  );
1274
1493
  this._currentRef = ref;
1275
1494
  if (ref === this._lastSentRef) {
@@ -1481,7 +1700,7 @@ ${err.stack}` : String(err);
1481
1700
  writeFileAt: async (relativePath, content) => {
1482
1701
  const filePath = join(this._rootPath, relativePath);
1483
1702
  await mkdir(dirname(filePath), { recursive: true });
1484
- await writeFile(filePath, content);
1703
+ await FsAgent._atomicWriteFile(filePath, content);
1485
1704
  },
1486
1705
  deleteFileAt: async (relativePath) => {
1487
1706
  await rm(join(this._rootPath, relativePath), {
@@ -39,7 +39,7 @@ export interface FsTree {
39
39
  /**
40
40
  * Type of file system change
41
41
  */
42
- export type FsChangeType = 'added' | 'modified' | 'deleted';
42
+ export type FsChangeType = 'added' | 'modified' | 'deleted' | 'safety-rescan';
43
43
  /**
44
44
  * File system change event
45
45
  */
@@ -80,6 +80,10 @@ export declare class FsScanner {
80
80
  private _bs;
81
81
  private _paused;
82
82
  private _missedChangesDuringPause;
83
+ /** Periodic full-rescan timer that catches events the native watcher drops. */
84
+ private _safetyTimer;
85
+ /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
86
+ private _stopRequested;
83
87
  constructor(rootPath: string, options?: FsScanOptions);
84
88
  get tree(): FsTree | null;
85
89
  get rootPath(): string;
@@ -88,6 +92,29 @@ export declare class FsScanner {
88
92
  private _scanDirectory;
89
93
  private _shouldIgnore;
90
94
  watch(): Promise<void>;
95
+ /**
96
+ * One safety-rescan pass: rescans the tree and, if its content differs from
97
+ * the previous scan (the native watcher dropped an event), emits a sync
98
+ * notification so syncToDb reconciles the drift. Paused/stopped scanners and
99
+ * scan failures are no-ops.
100
+ */
101
+ private _runSafetyRescan;
102
+ /**
103
+ * Path+blobId content fingerprint used by the safety rescan to detect drift
104
+ * the native watcher missed (mtime-independent, same idea as the agent's
105
+ * content key but local to the scanner).
106
+ * @param tree - The tree to fingerprint
107
+ * @returns A stable content key
108
+ */
109
+ private _safetyContentKey;
110
+ /**
111
+ * Extracts a readable message from a thrown value.
112
+ * @param err - The caught value
113
+ * @returns A message string
114
+ */
115
+ private static _errMessage;
116
+ /** Whether the host is Windows — gates Windows-specific watcher hardening. */
117
+ private static get _isWindows();
91
118
  private _handleFileChange;
92
119
  private _findTreeByPath;
93
120
  private _notifyChange;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Rljson fs-agent description",
5
5
  "homepage": "https://github.com/rljson/fs-agent",
6
6
  "bugs": "https://github.com/rljson/fs-agent/issues",