Improve local client

This commit is contained in:
Andras Schmelczer 2026-03-15 09:55:37 +00:00
commit bbec7f14dd
6 changed files with 276 additions and 55 deletions

View file

@ -228,3 +228,67 @@ test("parseArgs - throws on invalid log level", () => {
]); ]);
}, /Invalid log level/); }, /Invalid log level/);
}); });
test("parseArgs - reads required options from environment variables", () => {
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";
process.env.VAULTLINK_TOKEN = "env-token";
process.env.VAULTLINK_VAULT_NAME = "env-vault";
try {
const args = parseArgs(["node", "cli.js"]);
assert.equal(args.localPath, "/env/path");
assert.equal(args.remoteUri, "https://env.example.com");
assert.equal(args.token, "env-token");
assert.equal(args.vaultName, "env-vault");
} finally {
delete process.env.VAULTLINK_LOCAL_PATH;
delete process.env.VAULTLINK_REMOTE_URI;
delete process.env.VAULTLINK_TOKEN;
delete process.env.VAULTLINK_VAULT_NAME;
}
});
test("parseArgs - CLI arguments take precedence over environment variables", () => {
process.env.VAULTLINK_TOKEN = "env-token";
try {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"cli-token",
"-v",
"default"
]);
assert.equal(args.token, "cli-token");
} finally {
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;
}
});

View file

@ -1,4 +1,4 @@
import { Command } from "commander"; 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";
@ -25,41 +25,79 @@ export function parseArgs(argv: string[]): CliArgs {
"VaultLink Local CLI - Sync your vault to the local filesystem" "VaultLink Local CLI - Sync your vault to the local filesystem"
) )
.version(packageJson.version) .version(packageJson.version)
.option("-l, --local-path <path>", "Local directory path to sync") .addOption(
.option("-r, --remote-uri <uri>", "Remote server URI") new Option(
.option("-t, --token <token>", "Authentication token") "-l, --local-path <path>",
.option("-v, --vault-name <name>", "Vault name") "Local directory path to sync"
.option( ).env("VAULTLINK_LOCAL_PATH")
"--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations",
parseInt
) )
.option( .addOption(
"--max-file-size-mb <number>", new Option(
"[OPTIONAL] Maximum file size in MB", "-r, --remote-uri <uri>",
parseInt "Remote server URI"
).env("VAULTLINK_REMOTE_URI")
) )
.option( .addOption(
"--ignore-pattern <pattern...>", new Option(
"[OPTIONAL] Patterns to ignore (can be specified multiple times)" "-t, --token <token>",
"Authentication token"
).env("VAULTLINK_TOKEN")
) )
.option( .addOption(
"--websocket-retry-interval-ms <number>", new Option(
"[OPTIONAL] WebSocket retry interval in milliseconds", "-v, --vault-name <name>",
parseInt "Vault name"
).env("VAULTLINK_VAULT_NAME")
) )
.option( .addOption(
"--log-level <level>", new Option(
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "--sync-concurrency <number>",
"INFO" "[OPTIONAL] Number of concurrent sync operations"
)
.argParser(parseInt)
.env("VAULTLINK_SYNC_CONCURRENCY")
) )
.option( .addOption(
"--health <path>", new Option(
"[OPTIONAL] Path to health status file for Docker healthcheck" "--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
) )
.option( .addOption(
"--enable-telemetry", new Option(
"[OPTIONAL] Enable telemetry (disabled by default)" "--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
).env("VAULTLINK_IGNORE_PATTERNS")
)
.addOption(
new Option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds"
)
.argParser(parseInt)
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
)
.addOption(
new Option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
)
.default("INFO")
.env("VAULTLINK_LOG_LEVEL")
)
.addOption(
new Option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
).env("VAULTLINK_HEALTH")
)
.addOption(
new Option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
).env("VAULTLINK_ENABLE_TELEMETRY")
) )
.addHelpText( .addHelpText(
"after", "after",
@ -70,6 +108,10 @@ Examples:
--ignore-pattern ".git/**" --ignore-pattern "*.tmp" --ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--log-level DEBUG --log-level DEBUG
Environment variables:
All options can be configured via VAULTLINK_ prefixed environment variables.
CLI arguments take precedence over environment variables.
` `
); );
@ -92,20 +134,26 @@ Examples:
const enableTelemetry = opts.enableTelemetry as boolean | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined;
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
if (localPath === undefined) { const requireOption = <T>(
throw new Error( value: T | undefined,
"required option '-l, --local-path <path>' not specified" name: string
); ): T => {
} if (value === undefined) {
if (remoteUri === undefined) { const option = program.options.find(
throw new Error("required option '--remote-uri <uri>' not specified"); (o) => o.attributeName() === name
} );
if (token === undefined) { throw new Error(
throw new Error("required option '--token <token>' not specified"); `required option '${option?.flags ?? name}' not specified` +
} (option?.envVar ? ` (or set ${option.envVar})` : "")
if (vaultName === undefined) { );
throw new Error("required option '--vault-name <name>' not specified"); }
} return value;
};
const requiredLocalPath = requireOption(localPath, "localPath");
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token");
const requiredVaultName = requireOption(vaultName, "vaultName");
// Validate and parse log level // Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase(); const logLevelUpper = logLevelStr.toUpperCase();
@ -121,10 +169,10 @@ Examples:
const logLevel = logLevelUpper; const logLevel = logLevelUpper;
return { return {
localPath, localPath: requiredLocalPath,
remoteUri, remoteUri: requiredRemoteUri,
token, token: requiredToken,
vaultName, vaultName: requiredVaultName,
syncConcurrency, syncConcurrency,
maxFileSizeMB: maxFileSizeMb, maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern, ignorePatterns: ignorePattern,

View file

@ -146,11 +146,16 @@ async function main(): Promise<void> {
if (args.health !== undefined) { if (args.health !== undefined) {
const healthFile = args.health; const healthFile = args.health;
const healthInterval = setInterval(() => { const writeHealth = (): void => {
void client.checkConnection().then((status) => { void client.checkConnection().then((status) => {
writeHealthStatus(healthFile, status); writeHealthStatus(healthFile, status);
}); });
}, HEALTH_CHECK_INTERVAL_MS); };
writeHealth();
const healthInterval = setInterval(
writeHealth,
HEALTH_CHECK_INTERVAL_MS
);
const clearHealthInterval = (): void => { const clearHealthInterval = (): void => {
clearInterval(healthInterval); clearInterval(healthInterval);
}; };
@ -169,7 +174,7 @@ async function main(): Promise<void> {
client.logger.info("Starting sync client"); client.logger.info("Starting sync client");
const fileWatcher = new FileWatcher(absolutePath, client); const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
client.onWebSocketStatusChanged.add(() => { client.onWebSocketStatusChanged.add(() => {
const isConnected = client.isWebSocketConnected; const isConnected = client.isWebSocketConnected;
@ -186,7 +191,13 @@ async function main(): Promise<void> {
} }
}); });
let isShuttingDown = false;
const gracefulShutdown = async (signal: string): Promise<void> => { const gracefulShutdown = async (signal: string): Promise<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.log( console.log(
colorize( colorize(
`\n${signal} received. Shutting down gracefully...`, `\n${signal} received. Shutting down gracefully...`,

View file

@ -8,7 +8,8 @@ export class FileWatcher {
public constructor( public constructor(
private readonly basePath: string, private readonly basePath: string,
private readonly client: SyncClient private readonly client: SyncClient,
private readonly ignorePatterns: string[] = []
) {} ) {}
public start(): void { public start(): void {
@ -22,7 +23,8 @@ export class FileWatcher {
recursive: true, recursive: true,
renameDetection: true, renameDetection: true,
renameTimeout: 125, renameTimeout: 125,
ignoreInitial: true ignoreInitial: true,
ignore: (filePath: string) => this.shouldIgnore(filePath)
}); });
this.watcher.on("add", (filePath: string) => { this.watcher.on("add", (filePath: string) => {
@ -56,6 +58,19 @@ export class FileWatcher {
this.client.logger.info("File watcher stopped"); this.client.logger.info("File watcher stopped");
} }
private shouldIgnore(filePath: string): boolean {
const rel = path
.relative(this.basePath, filePath)
.replace(/\\/g, "/");
return this.ignorePatterns.some((pattern) => {
if (pattern.endsWith("/**")) {
const prefix = pattern.slice(0, -3);
return rel === prefix || rel.startsWith(prefix + "/");
}
return rel === pattern;
});
}
private handleCreate(relativePath: RelativePath): void { private handleCreate(relativePath: RelativePath): void {
this.client this.client
.syncLocallyCreatedFile(relativePath) .syncLocallyCreatedFile(relativePath)

View file

@ -0,0 +1,70 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import {
colorize,
styleText,
formatLogLine,
colors
} from "./logger-formatter";
import { LogLevel } from "sync-client";
test("colorize - wraps text with ANSI color codes", () => {
const result = colorize("hello", "red");
assert.equal(result, `${colors.red}hello${colors.reset}`);
});
test("styleText - applies multiple modifiers", () => {
const result = styleText("hello", "bold", "cyan");
assert.equal(
result,
`${colors.bold}${colors.cyan}hello${colors.reset}`
);
});
test("formatLogLine - includes level and message", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Test message"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("INFO"));
assert.ok(result.includes("Test message"));
});
test("formatLogLine - ERROR level messages contain bold escape", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.ERROR,
message: "Error occurred"
};
const result = formatLogLine(logLine);
assert.ok(result.includes(colors.bold));
});
test("formatLogLine - highlights file paths in quotes", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: 'Syncing "notes/test.md"'
};
const result = formatLogLine(logLine);
assert.ok(result.includes(colors.magenta));
});
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Listed 42 files from v1.2.3"
};
const result = formatLogLine(logLine);
// "42" should be colorized (standalone number)
assert.ok(result.includes(`${colors.cyan}42${colors.reset}`));
// "1", "2", "3" in "v1.2.3" should NOT be colorized individually
assert.ok(!result.includes(`${colors.cyan}1${colors.reset}.`));
});

View file

@ -47,7 +47,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
try { try {
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content); await this.atomicWrite(fullPath, content);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
@ -67,7 +67,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
try { try {
const currentContent = await fs.readFile(fullPath, "utf-8"); const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] }); const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8"); await this.atomicWrite(fullPath, result.text, "utf-8");
return result.text; return result.text;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -156,6 +156,19 @@ export class NodeFileSystemOperations implements FileSystemOperations {
} }
} }
private async atomicWrite(
fullPath: string,
content: Uint8Array | string,
encoding?: BufferEncoding
): Promise<void> {
const tmpPath = fullPath + ".tmp";
await fs.writeFile(tmpPath, content, encoding);
const fd = await fs.open(tmpPath, "r");
await fd.datasync();
await fd.close();
await fs.rename(tmpPath, fullPath);
}
private async walkDirectory( private async walkDirectory(
relativePath: string, relativePath: string,
files: RelativePath[] files: RelativePath[]