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

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));
}
}
}
}