This commit is contained in:
Andras Schmelczer 2026-09-23 08:23:17 +01:00
commit 4c002f9acd
9 changed files with 14543 additions and 4367 deletions

View file

@ -62,7 +62,6 @@ vaultlink \
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
@ -96,12 +95,7 @@ vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--log-level DEBUG --quiet
```
Force LF line endings (useful for cross-platform vaults):
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--line-endings lf
```
File bytes, including line endings, are preserved on every platform.
## Docker Deployment
@ -211,10 +205,21 @@ docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
1. Creates `.vaultlink` directory for sync metadata
2. Performs initial sync of local files to server
3. Watches filesystem for changes using Node's `fs.watch`
3. Uses filesystem events to wake a fresh scan, including atomic editor saves
4. Syncs changes bidirectionally in real-time
5. Handles graceful shutdown on SIGINT/SIGTERM
The complete client record (database, settings, queued logical notifications and
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
Existing database-only files migrate on the next save. Invalid or unreadable
metadata is reported instead of silently starting with empty state.
File moves use an exclusive copy followed by deletion of the source. An
interruption may leave both files; the next scan reconciles the visible files.
Raw watcher events do not distinguish user edits from sync writes. A rename
preserves identity when the scan finds a unique matching nonempty content hash;
a simultaneous rename and edit may instead appear as deletion and creation.
## License
MIT

View file

@ -2,9 +2,6 @@ import { Command, Option } from "commander";
import packageJson from "../package.json";
import { LogLevel } from "sync-client";
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
interface CliArgs {
remoteUri: string;
token: string;
@ -17,7 +14,6 @@ interface CliArgs {
health?: string;
enableTelemetry?: boolean;
quiet: boolean;
lineEndings: LineEndingMode;
}
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
@ -131,15 +127,6 @@ export function parseArgs(argv: string[]): CliArgs {
"[OPTIONAL] Suppress startup banner for non-interactive use"
).env("VAULTLINK_QUIET")
)
.addOption(
new Option(
"--line-endings <mode>",
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
)
.default("auto")
.choices([...LINE_ENDING_MODES])
.env("VAULTLINK_LINE_ENDINGS")
)
.addHelpText(
"after",
`
@ -173,7 +160,6 @@ Environment variables:
const health = opts.health as string | undefined;
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
const quiet = (opts.quiet as boolean | undefined) ?? false;
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
const requiredLocalPath = requireOption(localPath, "localPath");
@ -203,15 +189,6 @@ Environment variables:
}
const logLevel = logLevelUpper;
const isLineEndingMode = (value: string): value is LineEndingMode =>
(LINE_ENDING_MODES as readonly string[]).includes(value);
if (!isLineEndingMode(lineEndingsStr)) {
throw new Error(
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
);
}
const lineEndings = lineEndingsStr;
return {
localPath: requiredLocalPath,
remoteUri: requiredRemoteUri,
@ -223,7 +200,6 @@ Environment variables:
logLevel,
health,
enableTelemetry,
quiet,
lineEndings
quiet
};
}

View file

@ -1,19 +1,18 @@
import * as path from "path";
import * as fs from "fs/promises";
import * as fsSync from "fs";
import type { NetworkConnectionStatus ,
Logger} from "sync-client";
import type { NetworkConnectionStatus, Logger } from "sync-client";
import {
SyncClient,
DEFAULT_SETTINGS,
LogLevel,
LogLine,
type SyncSettings,
type StoredDatabase
type SyncSettings
} from "sync-client";
import { parseArgs, type LineEndingMode } from "./args";
import { parseArgs } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { FileWatcher } from "./file-watcher";
import { ClientPersistence } from "./client-persistence";
import { formatLogLine } from "./logger-formatter";
import packageJson from "../package.json";
@ -41,7 +40,6 @@ const LOG_LEVEL_ORDER = {
function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
return (logLine: LogLine): void => {
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) {
console.log(formatLogLine(logLine));
}
};
@ -50,17 +48,6 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
const PROGRESS_LOG_INTERVAL_MS = 2000;
function resolveLineEndings(mode: LineEndingMode): string {
switch (mode) {
case "lf":
return "\n";
case "crlf":
return "\r\n";
case "auto":
return process.platform === "win32" ? "\r\n" : "\n";
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
@ -96,12 +83,6 @@ async function main(): Promise<void> {
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
if (args.lineEndings !== "auto") {
emitBoot(
LogLevel.INFO,
`Line endings: ${args.lineEndings.toUpperCase()}`
);
}
}
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
@ -133,33 +114,7 @@ async function main(): Promise<void> {
const client = await SyncClient.create({
fs: fileSystem,
persistence: {
load: async () => {
let database: Partial<StoredDatabase> | undefined = undefined;
try {
const content = await fs.readFile(dataFile, "utf-8");
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>;
} catch {
emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
);
}
return {
settings,
database
};
},
save: async ({ database: persistedDatabase }) => {
await fs.writeFile(
dataFile,
JSON.stringify(persistedDatabase, null, 2)
);
}
},
nativeLineEndings: resolveLineEndings(args.lineEndings)
persistence: new ClientPersistence(dataFile, settings)
});
if (args.health !== undefined) {
@ -232,7 +187,6 @@ async function main(): Promise<void> {
client.logger.info(`${signal} received, shutting down gracefully`);
fileWatcher.stop();
await client.waitUntilFinished();
await client.destroy();
if (totalSyncOps > 0) {
@ -279,7 +233,6 @@ async function main(): Promise<void> {
}
main().catch((error: unknown) => {
console.error(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
);

View file

@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { ClientPersistence } from "./client-persistence";
import type { StoredClient } from "sync-client";
test("CLI persistence retains the whole record and migrates database-only saves", async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-metadata-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, "state.json");
const store = new ClientPersistence(file, { token: "current-token" });
assert.deepEqual(await store.load(), {
settings: { token: "current-token" }
});
const record: StoredClient = {
settings: { token: "old-token", vaultName: "vault" },
database: { lastSeenUpdateId: 12 },
localChanges: [
{ type: "delete", path: "removed.md", changeId: "queued" }
],
localChangesVaultKey: "vault",
historyCheckpoint: { vaultKey: "vault", checkpoint: "12:incarnation" }
};
await store.save(record);
assert.deepEqual(await store.load(), {
...record,
settings: { ...record.settings, token: "current-token" }
});
const legacy = {
fileManifest: { fileManifestId: 0, entries: {} },
lastSeenUpdateId: 0
};
await fs.writeFile(file, JSON.stringify(legacy));
assert.deepEqual((await store.load()).database, legacy);
await fs.writeFile(file, "{truncated");
await assert.rejects(store.load(), SyntaxError);
await fs.rm(file);
await fs.mkdir(file);
await assert.rejects(store.load(), /EISDIR/);
});
for (const afterReplacement of [false, true]) {
test(`failed metadata save ${afterReplacement ? "after" : "before"} replacement leaves a complete record`, async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-save-")
);
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const store = new ClientPersistence(
path.join(directory, "state.json"),
{}
);
const old: StoredClient = {
historyCheckpoint: { vaultKey: "v", checkpoint: "1:old" }
};
const next: StoredClient = {
...old,
localChanges: [{ type: "delete", path: "note.md", changeId: "new" }]
};
await store.save(old);
const rename = fs.rename;
t.mock.method(
fs,
"rename",
async (...args: Parameters<typeof fs.rename>) => {
if (afterReplacement) await rename(...args);
throw new Error("interrupted replacement");
}
);
await assert.rejects(store.save(next), /interrupted replacement/);
assert.deepEqual(await store.load(), {
...(afterReplacement ? next : old),
settings: {}
});
assert.deepEqual(await fs.readdir(directory), ["state.json"]);
});
}

View file

@ -0,0 +1,67 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import type {
PersistenceProvider,
StoredClient,
StoredDatabase,
SyncSettings
} from "sync-client";
/** Only client metadata needs an atomic replacement; user files do not. */
export class ClientPersistence implements PersistenceProvider<StoredClient> {
public constructor(
private readonly file: string,
private readonly settings: Partial<SyncSettings>
) {}
public async load(): Promise<StoredClient> {
const content = await fs
.readFile(this.file, "utf8")
.catch((error: unknown) => {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
)
return undefined;
throw error;
});
if (content === undefined) return { settings: this.settings };
const parsed: unknown = JSON.parse(content);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
)
throw new Error("Invalid client metadata");
// The sync engine validates and migrates the persisted fields.
const data = parsed as StoredClient & Partial<StoredDatabase>;
// Earlier CLI versions saved the database alone.
const stored: StoredClient =
"fileManifest" in data ? { database: data } : data;
return {
...stored,
settings: { ...stored.settings, ...this.settings }
};
}
public async save(data: StoredClient): Promise<void> {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.${randomUUID()}.tmp`;
try {
const handle = await fs.open(temporary, "wx", 0o600);
try {
await handle.writeFile(JSON.stringify(data));
// Flush the complete value before exposing its name. A crash can
// keep the previous value, but must not expose truncated JSON.
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(temporary, this.file);
} finally {
await fs.rm(temporary, { force: true });
}
}
}

View file

@ -1,93 +1,54 @@
import Watcher from "watcher";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
import * as path from "node:path";
import type { SyncClient } from "sync-client";
import { toUnixPath, matchesGlob } from "./path-utils";
/** OS events include sync writes and editor temporary-file replacements. They
* wake reconciliation; only a fresh scan determines the current namespace. */
export class FileWatcher {
private watcher: Watcher | undefined;
private isRunning = false;
private readonly ignorePatterns: string[];
public constructor(
private readonly basePath: string,
private readonly client: SyncClient,
ignorePatterns: string[] = []
) {
this.ignorePatterns = ignorePatterns;
}
private readonly ignorePatterns: string[] = []
) {}
public start(): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
if (this.watcher) return;
this.watcher = new Watcher(this.basePath, {
recursive: true,
renameDetection: true,
renameTimeout: 125,
renameDetection: false,
ignoreInitial: true,
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
ignore: (filePath: string): boolean =>
this.ignorePatterns.some((pattern) =>
matchesGlob(this.toRelativePath(filePath), pattern)
)
});
this.watcher.on("add", (filePath: string) => {
this.handleCreate(this.toRelativePath(filePath));
this.watcher.on("all", (_event: string, filePath: string) => {
void this.client
.syncLocallyUpdatedFile({
relativePath: this.toRelativePath(filePath)
})
.catch((error: unknown) => {
this.client.logger.error(
`File notification failed: ${error}`
);
});
});
this.watcher.on("change", (filePath: string) => {
this.handleChange(this.toRelativePath(filePath));
this.watcher.on("error", (error: Error) => {
this.client.logger.error(`File watcher failed: ${error.message}`);
});
this.watcher.on("unlink", (filePath: string) => {
this.handleDelete(this.toRelativePath(filePath));
});
this.watcher.on("rename", (oldPath: string, newPath: string) => {
this.handleRename(
this.toRelativePath(oldPath),
this.toRelativePath(newPath)
);
});
this.client.logger.info("File watcher started");
}
public stop(): void {
if (this.watcher !== undefined) {
this.watcher.close();
this.watcher = undefined;
}
this.isRunning = false;
this.watcher?.close();
this.watcher = undefined;
this.client.logger.info("File watcher stopped");
}
private shouldIgnore(filePath: string): boolean {
const rel = toUnixPath(path.relative(this.basePath, filePath));
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
}
private handleCreate(relativePath: RelativePath): void {
this.client.syncLocallyCreatedFile(relativePath);
}
private handleChange(relativePath: RelativePath): void {
this.client.syncLocallyUpdatedFile({ relativePath });
}
private handleDelete(relativePath: RelativePath): void {
this.client.syncLocallyDeletedFile(relativePath);
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
private toRelativePath(absolutePath: string): RelativePath {
private toRelativePath(absolutePath: string): string {
return toUnixPath(path.relative(this.basePath, absolutePath));
}
}

View file

@ -11,10 +11,13 @@ test("NodeFileSystemOperations - read and write files", async () => {
try {
const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content);
await fsOps.write("test.txt", { content });
const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
assert.equal(
new TextDecoder().decode(readContent!.content),
"Hello, world!"
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
@ -27,10 +30,13 @@ test("NodeFileSystemOperations - create nested directories with forward slashes"
try {
const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content);
await fsOps.write("dir1/dir2/test.txt", { content });
const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file");
assert.equal(
new TextDecoder().decode(readContent!.content),
"Nested file"
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
@ -43,7 +49,9 @@ test("NodeFileSystemOperations - exists with forward slashes", async () => {
try {
assert.equal(await fsOps.exists("test.txt"), false);
await fsOps.write("test.txt", new TextEncoder().encode("test"));
await fsOps.write("test.txt", {
content: new TextEncoder().encode("test")
});
assert.equal(await fsOps.exists("test.txt"), true);
} finally {
@ -51,16 +59,20 @@ test("NodeFileSystemOperations - exists with forward slashes", async () => {
}
});
test("NodeFileSystemOperations - delete with forward slashes", async () => {
test("NodeFileSystemOperations - deleteFile with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("test"));
await fsOps.write("test.txt", {
content: new TextEncoder().encode("test")
});
assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.delete("test.txt");
await fsOps.deleteFile("test.txt");
assert.equal(await fsOps.exists("test.txt"), false);
await fsOps.deleteFile("test.txt");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
@ -72,7 +84,7 @@ test("NodeFileSystemOperations - rename with forward slashes", async () => {
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
await fsOps.write("old.txt", { content });
await fsOps.rename("old.txt", "new.txt");
@ -80,7 +92,10 @@ test("NodeFileSystemOperations - rename with forward slashes", async () => {
assert.equal(await fsOps.exists("new.txt"), true);
const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content");
assert.equal(
new TextDecoder().decode(readContent!.content),
"test content"
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
@ -92,7 +107,7 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
await fsOps.write("old.txt", { content });
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
@ -103,42 +118,21 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
}
});
test("NodeFileSystemOperations - getFileSize", async () => {
test("NodeFileSystemOperations - stat", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content);
await fsOps.write("test.txt", { content });
const size = await fsOps.getFileSize("test.txt");
const size = (await fsOps.stat("test.txt"))!.size;
assert.equal(size, content.length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - atomicUpdateText", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World",
cursors: []
}));
assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
@ -148,15 +142,84 @@ test("NodeFileSystemOperations - handles paths with forward slashes on all platf
const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test");
await fsOps.write(testPath, content);
await fsOps.write(testPath, { content });
assert.equal(await fsOps.exists(testPath), true);
const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test");
assert.equal(new TextDecoder().decode(readContent!.content), "test");
await fsOps.delete(testPath);
await fsOps.deleteFile(testPath);
assert.equal(await fsOps.exists(testPath), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - deleteDirectory prunes empty directory trees", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fs.mkdir(path.join(tempDir, "parent", "child"), {
recursive: true
});
await fsOps.deleteDirectory("parent");
assert.equal(await fsOps.exists("parent"), false);
await fsOps.deleteDirectory("parent");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - deleteDirectory does not remove files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("parent/file.txt", {
content: new TextEncoder().encode("preserve me")
});
await assert.rejects(async () => fsOps.deleteDirectory("parent"));
assert.equal(await fsOps.exists("parent/file.txt"), true);
await assert.rejects(async () => fsOps.deleteDirectory("parent/file.txt"));
assert.equal(await fsOps.exists("parent/file.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - atomic text updates use current text and preserve bytes on updater failure", async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-text-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const disk = new NodeFileSystemOperations(root);
await disk.write("note.md", {
content: new TextEncoder().encode("original text")
});
assert.equal(
await disk.atomicUpdateText("note.md", (current) => {
assert.deepEqual(current, { text: "original text", cursors: [] });
return { text: "short", cursors: [] };
}),
"short"
);
await assert.rejects(
disk.atomicUpdateText("note.md", () => {
throw new Error("updater failed");
}),
/updater failed/
);
assert.equal(
await fs.readFile(path.join(root, "note.md"), "utf8"),
"short"
);
await assert.rejects(
disk.atomicUpdateText("missing.md", () => ({
text: "new",
cursors: []
}))
);
assert.equal(await disk.exists("missing.md"), false);
});

View file

@ -1,201 +1,231 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import assert from "node:assert/strict";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
closeSync,
constants,
openSync,
readFileSync,
writeFileSync,
type Stats
} from "node:fs";
import type {
FileSystemOperations,
RelativePath,
FileSnapshot,
TextWithCursors
} from "sync-client";
import { toUnixPath } from "./path-utils";
// VaultLink's per-vault metadata directory. Holds the persisted sync database
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
// ignore pattern keeps everything in here invisible to the file watcher.
function hasCode(error: unknown, ...codes: string[]): boolean {
return (
error instanceof Error &&
"code" in error &&
typeof error.code === "string" &&
codes.includes(error.code)
);
}
export const VAULTLINK_DIR = ".vaultlink";
/** Preflights reject unsafe paths and links. This is not a sandbox against a
* hostile process concurrently replacing ancestor directories. */
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) { }
public async listFilesRecursively(
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(directory ?? "", files);
return files;
public constructor(private readonly root: string) { }
public async stat(
relative: string
): ReturnType<FileSystemOperations["stat"]> {
const info = await this.info(await this.resolve(relative));
return info
? {
kind: info.isFile()
? ("file" as const)
: ("directory" as const),
size: info.size
}
: undefined;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(this.basePath, relativePath);
public async exists(relative: string): Promise<boolean> {
return (await this.stat(relative)) !== undefined;
}
public async read(
relative: string
): Promise<FileSnapshot | undefined> {
const absolute = await this.resolve(relative);
const handle = await fs
.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW)
.catch((error: unknown) => {
if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
throw error;
});
if (!handle) return undefined;
try {
return await fs.readFile(fullPath);
} catch (error) {
throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
const before = await handle.stat({ bigint: true });
assert(
before.isFile() && before.nlink === 1n,
"Not an independent regular file"
);
const content = new Uint8Array(await handle.readFile());
const after = await handle.stat({ bigint: true });
const atPath = await fs.lstat(absolute, { bigint: true });
assert(
before.ino === after.ino &&
after.ino === atPath.ino &&
before.size === after.size &&
before.mtimeNs === after.mtimeNs &&
before.ctimeNs === after.ctimeNs,
"File changed during read"
);
return { content };
} finally {
await handle.close();
}
}
public async listFilesRecursively(root = ""): Promise<string[]> {
const result: string[] = [];
for (const name of await fs.readdir(await this.resolve(root))) {
const relative = root ? `${root}/${name}` : name;
const entry = await this.stat(relative);
assert(entry, `File changed during scan: ${relative}`);
if (entry.kind === "directory")
result.push(...(await this.listFilesRecursively(relative)));
else result.push(relative);
}
return result.sort();
}
public async createDirectory(relative: string): Promise<void> {
await this.resolve(relative);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
const next = path.join(current, component);
try {
await fs.mkdir(next);
} catch (error) {
if (!hasCode(error, "EEXIST")) throw error;
}
assert((await fs.lstat(next)).isDirectory());
current = next;
}
}
public async write(
relativePath: RelativePath,
content: Uint8Array
relative: string,
snapshot: FileSnapshot
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const dir = path.dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await this.atomicWrite(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
const destination = await this.resolve(relative);
await this.createDirectory(
path.posix.dirname(relative) === "."
? ""
: path.posix.dirname(relative)
);
await fs.writeFile(destination, snapshot.content, {
flag:
constants.O_WRONLY |
constants.O_CREAT |
constants.O_TRUNC |
constants.O_NOFOLLOW,
mode: 0o600
});
}
public async atomicUpdateText(
relativePath: RelativePath,
relative: string,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(this.basePath, relativePath);
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await this.atomicWrite(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
const absolute = await this.resolve(relative);
// Keep the read, updater and write in one turn, without yielding to another update.
const text = readFileSync(absolute, "utf8");
const result = updater({ text, cursors: [] });
if (result.text !== text) {
const fd = openSync(
absolute,
constants.O_WRONLY | constants.O_TRUNC | constants.O_NOFOLLOW
);
}
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(this.basePath, relativePath);
try {
const stats = await fs.stat(fullPath);
return stats.size;
} catch (error) {
throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.unlink(fullPath);
} catch (error) {
throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(this.basePath, oldPath);
const newFullPath = path.join(this.basePath, newPath);
const newDir = path.dirname(newFullPath);
try {
await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath);
} catch (error) {
throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async atomicWrite(
fullPath: string,
content: Uint8Array | string,
encoding?: BufferEncoding
): Promise<void> {
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
await fs.mkdir(tmpDir, { recursive: true });
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
try {
await fs.writeFile(tmpPath, content, encoding);
const fd = await fs.open(tmpPath, "r");
try {
await fd.datasync();
writeFileSync(fd, result.text);
} finally {
await fd.close();
closeSync(fd);
}
await fs.rename(tmpPath, fullPath);
await this.syncDirectory(path.dirname(fullPath));
}
return result.text;
}
public async rename(from: string, to: string): Promise<void> {
const snapshot = await this.read(from);
assert(snapshot, `Missing source: ${from}`);
// Node's rename overwrites destinations on POSIX. Exclusive copy followed
// by unlink leaves ordinary discoverable files if interrupted.
await this.createDirectory(
path.posix.dirname(to) === "." ? "" : path.posix.dirname(to)
);
await fs.copyFile(
await this.resolve(from),
await this.resolve(to),
constants.COPYFILE_EXCL
);
const current = await this.read(from);
assert(
current &&
Buffer.from(current.content).equals(
Buffer.from(snapshot.content)
),
`Source changed during move: ${from}`
);
await this.deleteFile(from);
}
public async deleteDirectory(relative: string): Promise<void> {
assert(relative, "Cannot prune root");
const absolute = await this.resolve(relative);
const entry = await this.info(absolute);
if (!entry) return;
assert(entry.isDirectory(), "Cannot delete regular file");
for (const child of await fs.readdir(absolute))
await this.deleteDirectory(`${relative}/${child}`);
await fs.rmdir(absolute);
}
public async deleteFile(relative: string): Promise<void> {
const absolute = await this.resolve(relative);
const entry = await this.info(absolute);
if (!entry) return;
assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file");
await fs.unlink(absolute).catch((error: unknown) => {
if (!hasCode(error, "ENOENT")) throw error;
});
}
private async resolve(relative: string): Promise<string> {
assert(
relative === "" ||
relative
.split("/")
.every(
(p) =>
p !== "" &&
p !== "." &&
p !== ".." &&
!p.includes("\\") &&
!p.includes("\0") &&
(process.platform !== "win32" || !p.includes(":"))
),
"Unsafe path"
);
assert(
(await fs.lstat(this.root)).isDirectory(),
"Vault root must be a directory, not a symlink"
);
let current = this.root;
for (const component of relative.split("/").filter(Boolean)) {
current = path.join(current, component);
const entry = await this.info(current);
if (entry)
assert(
entry.isDirectory() ||
(entry.isFile() && entry.nlink === 1),
"Symlink, hardlink or special file"
);
}
return current;
}
private async info(absolute: string): Promise<Stats | undefined> {
try {
return await fs.lstat(absolute);
} catch (error) {
await fs.unlink(tmpPath).catch(() => undefined);
if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
throw error;
}
}
// Make the rename durable by fsync'ing the destination's parent directory.
// Skipped on Windows: fsync on a directory handle isn't supported there
private async syncDirectory(dir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
const fd = await fs.open(dir, "r");
try {
await fd.sync();
} finally {
await fd.close();
}
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = [];
try {
entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) {
throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
for (const entry of entries) {
const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(toUnixPath(entryRelativePath));
}
}
}
}

View file

@ -7,6 +7,7 @@ module.exports = {
healthcheck: "./src/healthcheck.ts"
},
target: "node",
externals: { bufferutil: "bufferutil", "utf-8-validate": "utf-8-validate" },
mode: "production",
optimization: {
minimize: false