diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index eb195538..46760c3b 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -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; + } +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 615b9d71..44a6dc1f 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -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 ", "Local directory path to sync") - .option("-r, --remote-uri ", "Remote server URI") - .option("-t, --token ", "Authentication token") - .option("-v, --vault-name ", "Vault name") - .option( - "--sync-concurrency ", - "[OPTIONAL] Number of concurrent sync operations", - parseInt + .addOption( + new Option( + "-l, --local-path ", + "Local directory path to sync" + ).env("VAULTLINK_LOCAL_PATH") ) - .option( - "--max-file-size-mb ", - "[OPTIONAL] Maximum file size in MB", - parseInt + .addOption( + new Option( + "-r, --remote-uri ", + "Remote server URI" + ).env("VAULTLINK_REMOTE_URI") ) - .option( - "--ignore-pattern ", - "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + .addOption( + new Option( + "-t, --token ", + "Authentication token" + ).env("VAULTLINK_TOKEN") ) - .option( - "--websocket-retry-interval-ms ", - "[OPTIONAL] WebSocket retry interval in milliseconds", - parseInt + .addOption( + new Option( + "-v, --vault-name ", + "Vault name" + ).env("VAULTLINK_VAULT_NAME") ) - .option( - "--log-level ", - "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", - "INFO" + .addOption( + new Option( + "--sync-concurrency ", + "[OPTIONAL] Number of concurrent sync operations" + ) + .argParser(parseInt) + .env("VAULTLINK_SYNC_CONCURRENCY") ) - .option( - "--health ", - "[OPTIONAL] Path to health status file for Docker healthcheck" + .addOption( + new Option( + "--max-file-size-mb ", + "[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 ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ).env("VAULTLINK_IGNORE_PATTERNS") + ) + .addOption( + new Option( + "--websocket-retry-interval-ms ", + "[OPTIONAL] WebSocket retry interval in milliseconds" + ) + .argParser(parseInt) + .env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS") + ) + .addOption( + new Option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)" + ) + .default("INFO") + .env("VAULTLINK_LOG_LEVEL") + ) + .addOption( + new Option( + "--health ", + "[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 ' not specified" - ); - } - if (remoteUri === undefined) { - throw new Error("required option '--remote-uri ' not specified"); - } - if (token === undefined) { - throw new Error("required option '--token ' not specified"); - } - if (vaultName === undefined) { - throw new Error("required option '--vault-name ' not specified"); - } + const requireOption = ( + 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, diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index c180f699..02f5b4f9 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -146,11 +146,16 @@ async function main(): Promise { 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 { 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 { } }); + let isShuttingDown = false; const gracefulShutdown = async (signal: string): Promise => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + console.log( colorize( `\n${signal} received. Shutting down gracefully...`, diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index f1e9198a..81e83cab 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -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) diff --git a/frontend/local-client-cli/src/logger-formatter.test.ts b/frontend/local-client-cli/src/logger-formatter.test.ts new file mode 100644 index 00000000..64768065 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.test.ts @@ -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}.`)); +}); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 474d6f58..734894a3 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -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 { + 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[]