Merge branch 'main' into asch/fix-everything
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-05-09 14:51:17 +01:00
commit 3a20a7c2f8
6 changed files with 120 additions and 298 deletions

View file

@ -150,25 +150,6 @@ test("parseArgs - default log level is INFO", () => {
assert.equal(args.logLevel, LogLevel.INFO); assert.equal(args.logLevel, LogLevel.INFO);
}); });
test("parseArgs - parse DEBUG log level", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"DEBUG"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - parse ERROR log level", () => { test("parseArgs - parse ERROR log level", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
@ -188,43 +169,6 @@ test("parseArgs - parse ERROR log level", () => {
assert.equal(args.logLevel, LogLevel.ERROR); assert.equal(args.logLevel, LogLevel.ERROR);
}); });
test("parseArgs - log level is case insensitive", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"debug"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - throws on invalid log level", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"INVALID"
]);
}, /Invalid log level/);
});
test("parseArgs - reads required options from environment variables", () => { test("parseArgs - reads required options from environment variables", () => {
process.env.VAULTLINK_LOCAL_PATH = "/env/path"; process.env.VAULTLINK_LOCAL_PATH = "/env/path";
@ -267,184 +211,3 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
delete process.env.VAULTLINK_TOKEN; delete process.env.VAULTLINK_TOKEN;
} }
}); });
test("parseArgs - reads log level from environment variable", () => {
process.env.VAULTLINK_LOG_LEVEL = "DEBUG";
try {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
} finally {
delete process.env.VAULTLINK_LOG_LEVEL;
}
});
test("parseArgs - quiet defaults to false", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.quiet, false);
});
test("parseArgs - parse --quiet flag", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--quiet"
]);
assert.equal(args.quiet, true);
});
test("parseArgs - parse -q short flag", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"-q"
]);
assert.equal(args.quiet, true);
});
test("parseArgs - line-endings defaults to auto", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.lineEndings, "auto");
});
test("parseArgs - parse --line-endings lf", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--line-endings",
"lf"
]);
assert.equal(args.lineEndings, "lf");
});
test("parseArgs - parse --line-endings crlf", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--line-endings",
"crlf"
]);
assert.equal(args.lineEndings, "crlf");
});
test("parseArgs - throws on invalid remote URI protocol", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"ftp://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
}, /Invalid remote URI/);
});
test("parseArgs - accepts http:// remote URI", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"http://localhost:3000",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.remoteUri, "http://localhost:3000");
});
test("parseArgs - accepts wss:// remote URI", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"wss://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.remoteUri, "wss://sync.example.com");
});

View file

@ -2,7 +2,8 @@ import { Command, Option } from "commander";
import packageJson from "../package.json"; import packageJson from "../package.json";
import { LogLevel } from "sync-client"; import { LogLevel } from "sync-client";
type LineEndingMode = "auto" | "lf" | "crlf"; export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
interface CliArgs { interface CliArgs {
remoteUri: string; remoteUri: string;
@ -21,6 +22,35 @@ interface CliArgs {
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
const REQUIRED_OPTIONS = {
localPath: {
flags: "-l, --local-path <path>",
env: "VAULTLINK_LOCAL_PATH"
},
remoteUri: {
flags: "-r, --remote-uri <uri>",
env: "VAULTLINK_REMOTE_URI"
},
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
vaultName: {
flags: "-v, --vault-name <name>",
env: "VAULTLINK_VAULT_NAME"
}
} as const;
function requireOption<T>(
value: T | undefined,
name: keyof typeof REQUIRED_OPTIONS
): T {
if (value === undefined) {
const { flags, env } = REQUIRED_OPTIONS[name];
throw new Error(
`required option '${flags}' not specified (or set ${env})`
);
}
return value;
}
export function parseArgs(argv: string[]): CliArgs { export function parseArgs(argv: string[]): CliArgs {
const program = new Command(); const program = new Command();
@ -32,23 +62,25 @@ export function parseArgs(argv: string[]): CliArgs {
.version(packageJson.version) .version(packageJson.version)
.addOption( .addOption(
new Option( new Option(
"-l, --local-path <path>", REQUIRED_OPTIONS.localPath.flags,
"Local directory path to sync" "Local directory path to sync"
).env("VAULTLINK_LOCAL_PATH") ).env(REQUIRED_OPTIONS.localPath.env)
) )
.addOption( .addOption(
new Option("-r, --remote-uri <uri>", "Remote server URI").env( new Option(
"VAULTLINK_REMOTE_URI" REQUIRED_OPTIONS.remoteUri.flags,
) "Remote server URI"
).env(REQUIRED_OPTIONS.remoteUri.env)
) )
.addOption( .addOption(
new Option("-t, --token <token>", "Authentication token").env( new Option(
"VAULTLINK_TOKEN" REQUIRED_OPTIONS.token.flags,
) "Authentication token"
).env(REQUIRED_OPTIONS.token.env)
) )
.addOption( .addOption(
new Option("-v, --vault-name <name>", "Vault name").env( new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
"VAULTLINK_VAULT_NAME" REQUIRED_OPTIONS.vaultName.env
) )
) )
.addOption( .addOption(
@ -105,7 +137,7 @@ export function parseArgs(argv: string[]): CliArgs {
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf" "[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
) )
.default("auto") .default("auto")
.choices(["auto", "lf", "crlf"]) .choices([...LINE_ENDING_MODES])
.env("VAULTLINK_LINE_ENDINGS") .env("VAULTLINK_LINE_ENDINGS")
) )
.addHelpText( .addHelpText(
@ -144,22 +176,6 @@ Environment variables:
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
const requireOption = <T>(value: T | undefined, name: string): T => {
if (value === undefined) {
const option = program.options.find(
(o) => o.attributeName() === name
);
const envHint =
option?.envVar !== undefined
? ` (or set ${option.envVar})`
: "";
throw new Error(
`required option '${option?.flags ?? name}' not specified${envHint}`
);
}
return value;
};
const requiredLocalPath = requireOption(localPath, "localPath"); const requiredLocalPath = requireOption(localPath, "localPath");
const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token"); const requiredToken = requireOption(token, "token");
@ -187,13 +203,11 @@ Environment variables:
} }
const logLevel = logLevelUpper; const logLevel = logLevelUpper;
const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; const isLineEndingMode = (value: string): value is LineEndingMode =>
const isLineEndingMode = (value: string): value is LineEndingMode => { (LINE_ENDING_MODES as readonly string[]).includes(value);
return validLineEndings.includes(value);
};
if (!isLineEndingMode(lineEndingsStr)) { if (!isLineEndingMode(lineEndingsStr)) {
throw new Error( throw new Error(
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
); );
} }
const lineEndings = lineEndingsStr; const lineEndings = lineEndingsStr;

View file

@ -7,12 +7,12 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
Logger, Logger,
LogLevel, LogLevel,
type LogLine, LogLine,
type SyncSettings, type SyncSettings,
type StoredDatabase type StoredDatabase
} from "sync-client"; } from "sync-client";
import { parseArgs } from "./args"; import { parseArgs, type LineEndingMode } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem"; import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { FileWatcher } from "./file-watcher"; import { FileWatcher } from "./file-watcher";
import { formatLogLine } from "./logger-formatter"; import { formatLogLine } from "./logger-formatter";
import packageJson from "../package.json"; import packageJson from "../package.json";
@ -50,7 +50,7 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
const PROGRESS_LOG_INTERVAL_MS = 2000; const PROGRESS_LOG_INTERVAL_MS = 2000;
function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string { function resolveLineEndings(mode: LineEndingMode): string {
switch (mode) { switch (mode) {
case "lf": case "lf":
return "\n"; return "\n";
@ -65,9 +65,13 @@ async function main(): Promise<void> {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath); const absolutePath = path.resolve(args.localPath);
const logger = new Logger();
const logHandler = createLogHandler(args.logLevel); const logHandler = createLogHandler(args.logLevel);
logger.onLogEmitted.add(logHandler); // Boot-time messages are emitted directly through logHandler before the
// SyncClient (and its Logger) exist; afterwards every log line flows
// through client.logger.
const emitBoot = (level: LogLevel, message: string): void => {
logHandler(new LogLine(level, message));
};
if (!fsSync.existsSync(absolutePath)) { if (!fsSync.existsSync(absolutePath)) {
fsSync.mkdirSync(absolutePath, { recursive: true }); fsSync.mkdirSync(absolutePath, { recursive: true });
@ -76,27 +80,31 @@ async function main(): Promise<void> {
try { try {
const stats = await fs.stat(absolutePath); const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
logger.error(`${absolutePath} is not a directory`); emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
process.exit(1); process.exit(1);
} }
} catch (error) { } catch (error) {
logger.error( emitBoot(
LogLevel.ERROR,
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
); );
process.exit(1); process.exit(1);
} }
if (!args.quiet) { if (!args.quiet) {
logger.info(`VaultLink Local CLI v${packageJson.version}`); emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
logger.info(`Local path: ${absolutePath}`); emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
logger.info(`Remote URI: ${args.remoteUri}`); emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
logger.info(`Vault name: ${args.vaultName}`); emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
if (args.lineEndings !== "auto") { if (args.lineEndings !== "auto") {
logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`); emitBoot(
LogLevel.INFO,
`Line endings: ${args.lineEndings.toUpperCase()}`
);
} }
} }
const dataDir = path.join(absolutePath, ".vaultlink"); const dataDir = path.join(absolutePath, VAULTLINK_DIR);
const dataFile = path.join(dataDir, "sync-data.json"); const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true }); await fs.mkdir(dataDir, { recursive: true });
@ -105,8 +113,7 @@ async function main(): Promise<void> {
const ignorePatterns = [ const ignorePatterns = [
...(args.ignorePatterns ?? []), ...(args.ignorePatterns ?? []),
".vaultlink/**", `${VAULTLINK_DIR}/**`
".git/**"
]; ];
const settings: SyncSettings = { const settings: SyncSettings = {
@ -134,7 +141,10 @@ async function main(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>; database = JSON.parse(content) as Partial<StoredDatabase>;
} catch { } catch {
logger.warn(`Cannot read data file at ${dataFile}`); emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
);
} }
return { return {
@ -269,7 +279,6 @@ async function main(): Promise<void> {
} }
main().catch((error: unknown) => { main().catch((error: unknown) => {
// Last-resort handler before the logger exists
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}` `Unexpected error: ${error instanceof Error ? error.message : String(error)}`

View file

@ -1,6 +1,7 @@
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import type { Dirent } from "fs"; import type { Dirent } from "fs";
import * as path from "path"; import * as path from "path";
import { randomUUID } from "crypto";
import type { import type {
FileSystemOperations, FileSystemOperations,
RelativePath, RelativePath,
@ -8,8 +9,13 @@ import type {
} from "sync-client"; } from "sync-client";
import { toUnixPath } from "./path-utils"; 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.
export const VAULTLINK_DIR = ".vaultlink";
export class NodeFileSystemOperations implements FileSystemOperations { export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {} public constructor(private readonly basePath: string) { }
public async listFilesRecursively( public async listFilesRecursively(
directory: RelativePath | undefined directory: RelativePath | undefined
@ -132,12 +138,37 @@ export class NodeFileSystemOperations implements FileSystemOperations {
content: Uint8Array | string, content: Uint8Array | string,
encoding?: BufferEncoding encoding?: BufferEncoding
): Promise<void> { ): Promise<void> {
const tmpPath = fullPath + ".tmp"; 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); await fs.writeFile(tmpPath, content, encoding);
const fd = await fs.open(tmpPath, "r"); const fd = await fs.open(tmpPath, "r");
try {
await fd.datasync(); await fd.datasync();
} finally {
await fd.close(); await fd.close();
}
await fs.rename(tmpPath, fullPath); await fs.rename(tmpPath, fullPath);
await this.syncDirectory(path.dirname(fullPath));
} catch (error) {
await fs.unlink(tmpPath).catch(() => 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( private async walkDirectory(

View file

@ -5,8 +5,14 @@ export function toUnixPath(nativePath: string): string {
return nativePath.split(path.sep).join(path.posix.sep); return nativePath.split(path.sep).join(path.posix.sep);
} }
// Match a file path against a glob pattern // Match a file path against a glob pattern.
// Extends path.matchesGlob so that "dir/**" also matches the directory itself //
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
// the directory `dir` itself, not only its descendants. The watcher feeds us
// a directory's relative path (e.g. ".git") at the same time it's about to
// recurse into it, and the natural way for users to write the ignore pattern
// is `.git/**` — under stdlib semantics that pattern would let the directory
// through and only block its children, defeating the prune.
export function matchesGlob(filePath: string, pattern: string): boolean { export function matchesGlob(filePath: string, pattern: string): boolean {
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
return true; return true;

View file

@ -47,7 +47,6 @@ module.exports = (env, argv) => ({
const destinations = [ const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link" "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
]; ];
destinations.forEach((destination) => { destinations.forEach((destination) => {
fs.copy(source, destination) fs.copy(source, destination)