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);
});
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", () => {
const args = parseArgs([
"node",
@ -188,43 +169,6 @@ test("parseArgs - parse ERROR log level", () => {
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", () => {
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;
}
});
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 { 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 {
remoteUri: string;
@ -21,6 +22,35 @@ interface CliArgs {
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 {
const program = new Command();
@ -32,23 +62,25 @@ export function parseArgs(argv: string[]): CliArgs {
.version(packageJson.version)
.addOption(
new Option(
"-l, --local-path <path>",
REQUIRED_OPTIONS.localPath.flags,
"Local directory path to sync"
).env("VAULTLINK_LOCAL_PATH")
).env(REQUIRED_OPTIONS.localPath.env)
)
.addOption(
new Option("-r, --remote-uri <uri>", "Remote server URI").env(
"VAULTLINK_REMOTE_URI"
)
new Option(
REQUIRED_OPTIONS.remoteUri.flags,
"Remote server URI"
).env(REQUIRED_OPTIONS.remoteUri.env)
)
.addOption(
new Option("-t, --token <token>", "Authentication token").env(
"VAULTLINK_TOKEN"
)
new Option(
REQUIRED_OPTIONS.token.flags,
"Authentication token"
).env(REQUIRED_OPTIONS.token.env)
)
.addOption(
new Option("-v, --vault-name <name>", "Vault name").env(
"VAULTLINK_VAULT_NAME"
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
REQUIRED_OPTIONS.vaultName.env
)
)
.addOption(
@ -105,7 +137,7 @@ export function parseArgs(argv: string[]): CliArgs {
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
)
.default("auto")
.choices(["auto", "lf", "crlf"])
.choices([...LINE_ENDING_MODES])
.env("VAULTLINK_LINE_ENDINGS")
)
.addHelpText(
@ -144,22 +176,6 @@ Environment variables:
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
/* 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 requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token");
@ -187,13 +203,11 @@ Environment variables:
}
const logLevel = logLevelUpper;
const validLineEndings: readonly string[] = ["auto", "lf", "crlf"];
const isLineEndingMode = (value: string): value is LineEndingMode => {
return validLineEndings.includes(value);
};
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: ${validLineEndings.join(", ")}`
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
);
}
const lineEndings = lineEndingsStr;

View file

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

View file

@ -1,6 +1,7 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import type {
FileSystemOperations,
RelativePath,
@ -8,6 +9,11 @@ import type {
} 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.
export const VAULTLINK_DIR = ".vaultlink";
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) { }
@ -132,12 +138,37 @@ export class NodeFileSystemOperations implements FileSystemOperations {
content: Uint8Array | string,
encoding?: BufferEncoding
): 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);
const fd = await fs.open(tmpPath, "r");
try {
await fd.datasync();
} finally {
await fd.close();
}
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(

View file

@ -5,8 +5,14 @@ export function toUnixPath(nativePath: string): string {
return nativePath.split(path.sep).join(path.posix.sep);
}
// Match a file path against a glob pattern
// Extends path.matchesGlob so that "dir/**" also matches the directory itself
// Match a file path against a glob pattern.
//
// 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 {
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
return true;

View file

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