Fix file watching

This commit is contained in:
Andras Schmelczer 2025-12-07 15:41:55 +00:00
parent d3fed45446
commit adcb031d2f
3 changed files with 152 additions and 86 deletions

View file

@ -12,7 +12,8 @@
"test": "tsx --test 'src/**/*.test.ts'" "test": "tsx --test 'src/**/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"commander": "^14.0.2" "commander": "^14.0.2",
"watcher": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.8.1", "@types/node": "^24.8.1",

View file

@ -1,102 +1,121 @@
import * as fs from "fs"; import Watcher from "watcher";
import * as path from "path"; import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client"; import type { SyncClient, RelativePath } from "sync-client";
export class FileWatcher { export class FileWatcher {
private watcher: fs.FSWatcher | undefined; private watcher: Watcher | undefined;
private isRunning = false; private isRunning = false;
public constructor( public constructor(
private readonly basePath: string, private readonly basePath: string,
private readonly client: SyncClient private readonly client: SyncClient
) {} ) {}
public start(): void { public start(): void {
if (this.isRunning) { if (this.isRunning) {
return; return;
} }
this.isRunning = true; this.isRunning = true;
this.watcher = fs.watch( this.watcher = new Watcher(this.basePath, {
this.basePath, recursive: true,
{ recursive: true }, renameDetection: true,
(eventType, filename) => { renameTimeout: 125,
if (filename === null || filename.length === 0) { ignoreInitial: true
return; });
}
// Convert to forward slashes for consistency this.watcher.on("add", (filePath: string) => {
const relativePath = this.toUnixPath(filename); this.handleCreate(this.toRelativePath(filePath));
});
if (eventType === "rename") { this.watcher.on("change", (filePath: string) => {
this.handleRenameOrDelete(relativePath); this.handleChange(this.toRelativePath(filePath));
} else { });
// Must be "change" event
this.handleChange(relativePath);
}
}
);
this.client.logger.info("File watcher started"); this.watcher.on("unlink", (filePath: string) => {
} this.handleDelete(this.toRelativePath(filePath));
});
public stop(): void { this.watcher.on("rename", (oldPath: string, newPath: string) => {
if (this.watcher !== undefined) { this.handleRename(
this.watcher.close(); this.toRelativePath(oldPath),
this.watcher = undefined; this.toRelativePath(newPath)
} );
this.isRunning = false; });
this.client.logger.info("File watcher stopped");
}
private handleChange(relativePath: RelativePath): void { this.client.logger.info("File watcher started");
this.client }
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
);
});
}
private handleRenameOrDelete(relativePath: RelativePath): void { public stop(): void {
const fullPath = path.join(this.basePath, relativePath); if (this.watcher !== undefined) {
this.watcher.close();
this.watcher = undefined;
}
this.isRunning = false;
this.client.logger.info("File watcher stopped");
}
fs.access(fullPath, fs.constants.F_OK, (accessError) => { private handleCreate(relativePath: RelativePath): void {
if (accessError) { this.client
this.client .syncLocallyCreatedFile(relativePath)
.syncLocallyDeletedFile(relativePath) .catch((err: unknown) => {
.catch((deleteErr: unknown) => { this.client.logger.error(
this.client.logger.error( `Failed to sync created file ${relativePath}: ${this.formatError(err)}`
`Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}` );
); });
}); }
} else {
fs.stat(fullPath, (statErr, stats) => {
if (statErr !== null || !stats.isFile()) {
return;
}
this.client private handleChange(relativePath: RelativePath): void {
.syncLocallyCreatedFile(relativePath) this.client
.catch((createErr: unknown) => { .syncLocallyUpdatedFile({ relativePath })
this.client.logger.error( .catch((err: unknown) => {
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}` this.client.logger.error(
); `Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
}); );
}); });
} }
});
}
/** private handleDelete(relativePath: RelativePath): void {
* Convert a native platform path to forward slashes this.client
*/ .syncLocallyDeletedFile(relativePath)
private toUnixPath(nativePath: string): string { .catch((err: unknown) => {
if (path.sep === "\\") { this.client.logger.error(
return nativePath.replace(/\\/g, "/"); `Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
} );
return nativePath; });
} }
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
}
private toRelativePath(absolutePath: string): RelativePath {
const relative = path.relative(this.basePath, absolutePath);
return this.toUnixPath(relative);
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
private formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
} }

View file

@ -24,7 +24,8 @@
"local-client-cli": { "local-client-cli": {
"version": "0.12.0", "version": "0.12.0",
"dependencies": { "dependencies": {
"commander": "^14.0.2" "commander": "^14.0.2",
"watcher": "^2.3.1"
}, },
"bin": { "bin": {
"vaultlink": "dist/cli.js" "vaultlink": "dist/cli.js"
@ -2452,6 +2453,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/dettle": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz",
"integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==",
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
@ -5646,6 +5653,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/promise-make-counter": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/promise-make-counter/-/promise-make-counter-1.0.2.tgz",
"integrity": "sha512-FJAxTBWQuQoAs4ZOYuKX1FHXxEgKLEzBxUvwr4RoOglkTpOjWuM+RXsK3M9q5lMa8kjqctUrhwYeZFT4ygsnag==",
"license": "MIT",
"dependencies": {
"promise-make-naked": "^3.0.2"
}
},
"node_modules/promise-make-naked": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/promise-make-naked/-/promise-make-naked-3.0.2.tgz",
"integrity": "sha512-B+b+kQ1YrYS7zO7P7bQcoqqMUizP06BOyNSBEnB5VJKDSWo8fsVuDkfSmwdjF0JsRtaNh83so5MMFJ95soH5jg==",
"license": "MIT"
},
"node_modules/pseudomap": { "node_modules/pseudomap": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@ -6398,6 +6420,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stubborn-fs": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz",
"integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g=="
},
"node_modules/style-mod": { "node_modules/style-mod": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
@ -6697,6 +6724,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tiny-readdir": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz",
"integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==",
"license": "MIT",
"dependencies": {
"promise-make-counter": "^1.0.2"
}
},
"node_modules/to-absolute-glob": { "node_modules/to-absolute-glob": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz",
@ -7076,6 +7112,16 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/watcher": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/watcher/-/watcher-2.3.1.tgz",
"integrity": "sha512-d3yl+ey35h05r5EFP0TafE2jsmQUJ9cc2aernRVyAkZiu0J3+3TbNugNcqdUJDoWOfL2p+bNsN427stsBC/HnA==",
"dependencies": {
"dettle": "^1.0.2",
"stubborn-fs": "^1.2.5",
"tiny-readdir": "^2.7.2"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.4.2", "version": "2.4.2",
"dev": true, "dev": true,