Improve local client

This commit is contained in:
Andras Schmelczer 2026-03-15 09:55:37 +00:00
parent a75b3469a3
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/);
});
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 { LogLevel } from "sync-client";
@ -25,41 +25,79 @@ export function parseArgs(argv: string[]): CliArgs {
"VaultLink Local CLI - Sync your vault to the local filesystem"
)
.version(packageJson.version)
.option("-l, --local-path <path>", "Local directory path to sync")
.option("-r, --remote-uri <uri>", "Remote server URI")
.option("-t, --token <token>", "Authentication token")
.option("-v, --vault-name <name>", "Vault name")
.option(
"--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations",
parseInt
.addOption(
new Option(
"-l, --local-path <path>",
"Local directory path to sync"
).env("VAULTLINK_LOCAL_PATH")
)
.option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB",
parseInt
.addOption(
new Option(
"-r, --remote-uri <uri>",
"Remote server URI"
).env("VAULTLINK_REMOTE_URI")
)
.option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
.addOption(
new Option(
"-t, --token <token>",
"Authentication token"
).env("VAULTLINK_TOKEN")
)
.option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt
.addOption(
new Option(
"-v, --vault-name <name>",
"Vault name"
).env("VAULTLINK_VAULT_NAME")
)
.option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO"
.addOption(
new Option(
"--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations"
)
.argParser(parseInt)
.env("VAULTLINK_SYNC_CONCURRENCY")
)
.option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
.addOption(
new Option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
)
.option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
.addOption(
new Option(
"--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(
"after",
@ -70,6 +108,10 @@ Examples:
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--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;
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
if (localPath === undefined) {
throw new Error(
"required option '-l, --local-path <path>' not specified"
);
}
if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified");
}
const requireOption = <T>(
value: T | undefined,
name: string
): T => {
if (value === undefined) {
const option = program.options.find(
(o) => o.attributeName() === name
);
throw new Error(
`required option '${option?.flags ?? name}' not specified` +
(option?.envVar ? ` (or set ${option.envVar})` : "")
);
}
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
const logLevelUpper = logLevelStr.toUpperCase();
@ -121,10 +169,10 @@ Examples:
const logLevel = logLevelUpper;
return {
localPath,
remoteUri,
token,
vaultName,
localPath: requiredLocalPath,
remoteUri: requiredRemoteUri,
token: requiredToken,
vaultName: requiredVaultName,
syncConcurrency,
maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern,

View file

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

View file

@ -8,7 +8,8 @@ export class FileWatcher {
public constructor(
private readonly basePath: string,
private readonly client: SyncClient
private readonly client: SyncClient,
private readonly ignorePatterns: string[] = []
) {}
public start(): void {
@ -22,7 +23,8 @@ export class FileWatcher {
recursive: true,
renameDetection: true,
renameTimeout: 125,
ignoreInitial: true
ignoreInitial: true,
ignore: (filePath: string) => this.shouldIgnore(filePath)
});
this.watcher.on("add", (filePath: string) => {
@ -56,6 +58,19 @@ export class FileWatcher {
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 {
this.client
.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 {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content);
await this.atomicWrite(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
@ -67,7 +67,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
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;
} catch (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(
relativePath: string,
files: RelativePath[]