diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 695ab587..0dfa7055 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS builder +FROM node:25-slim AS builder WORKDIR /build @@ -7,7 +7,7 @@ COPY . . RUN npm ci RUN npm run build -FROM node:22-alpine +FROM node:25-alpine LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md index 0585bacc..e91322f9 100644 --- a/frontend/local-client-cli/README.md +++ b/frontend/local-client-cli/README.md @@ -47,24 +47,25 @@ vaultlink \ ### Required -| Option | Description | -|--------|-------------| -| `-l, --local-path ` | Local directory to sync | -| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | -| `-t, --token ` | Authentication token | -| `-v, --vault-name ` | Vault name on server | +| Option | Description | +| ------------------------- | --------------------------------------------- | +| `-l, --local-path ` | Local directory to sync | +| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | +| `-t, --token ` | Authentication token | +| `-v, --vault-name ` | Vault name on server | ### Optional -| Option | Default | Description | -|--------|---------|-------------| -| `--sync-concurrency ` | `1` | Concurrent sync operations | -| `--max-file-size-mb ` | `10` | Maximum file size in MB | -| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | -| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | -| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | -| `-h, --help` | - | Show help | -| `-V, --version` | - | Show version | +| Option | Default | Description | +| ------------------------------------ | ------- | ----------------------------------------------- | +| `--max-file-size-mb ` | `10` | Maximum file size in MB | +| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | +| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | +| `--line-endings ` | `auto` | Line ending style: auto, lf, crlf | +| `-q, --quiet` | - | Suppress startup banner for non-interactive use | +| `-h, --help` | - | Show help | +| `-V, --version` | - | Show version | ### Auto-Ignored Patterns @@ -74,22 +75,32 @@ vaultlink \ ### Examples Basic usage: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default ``` With ignore patterns: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --ignore-pattern "*.tmp" \ + --ignore-pattern "**/*.tmp" \ --ignore-pattern ".DS_Store" \ --ignore-pattern "node_modules/**" ``` -With debug logging: +With debug logging and quiet startup: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --log-level DEBUG + --log-level DEBUG --quiet +``` + +Force LF line endings (useful for cross-platform vaults): + +```bash +vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ + --line-endings lf ``` ## Docker Deployment @@ -176,6 +187,7 @@ services: ## Development Build: + ```bash npm run build # or from the parent folder, run @@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile . ``` Test: + ```bash npm test ``` Docker build: + ```bash cd frontend docker build -f local-client-cli/Dockerfile -t vault-link-cli:test . diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index cade4990..a862b297 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -11,18 +11,16 @@ "build": "webpack --mode production", "test": "tsx --test 'src/**/*.test.ts'" }, - "dependencies": { - "commander": "^14.0.2", - "watcher": "^2.3.1" - }, "devDependencies": { - "@types/node": "^24.8.1", + "commander": "^14.0.2", + "watcher": "^2.3.1", + "@types/node": "^25.0.2", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "tslib": "2.8.1", - "tsx": "^4.20.6", - "typescript": "5.8.3", - "webpack": "^5.99.9", + "tsx": "^4.21.0", + "typescript": "5.9.3", + "webpack": "^5.103.0", "webpack-cli": "^6.0.1" } } diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index eb195538..c075d193 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => { "mytoken", "-v", "default", - "--sync-concurrency", - "5", "--max-file-size-mb", "20" ]); - assert.equal(args.syncConcurrency, 5); assert.equal(args.maxFileSizeMB, 20); }); @@ -228,3 +225,226 @@ 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; + } +}); + +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"); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 615b9d71..5124b72f 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,21 +1,26 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; +export type LineEndingMode = "auto" | "lf" | "crlf"; + export interface CliArgs { remoteUri: string; token: string; vaultName: string; localPath: string; - syncConcurrency?: number; maxFileSizeMB?: number; ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; logLevel: LogLevel; health?: string; enableTelemetry?: boolean; + quiet: boolean; + lineEndings: LineEndingMode; } +const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; + export function parseArgs(argv: string[]): CliArgs { const program = new Command(); @@ -25,41 +30,86 @@ 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( + "--max-file-size-mb ", + "[OPTIONAL] Maximum file size in MB" + ) + .argParser(parseInt) + .env("VAULTLINK_MAX_FILE_SIZE_MB") ) - .option( - "--health ", - "[OPTIONAL] Path to health status file for Docker healthcheck" + .addOption( + new Option( + "--ignore-pattern ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ).env("VAULTLINK_IGNORE_PATTERNS") ) - .option( - "--enable-telemetry", - "[OPTIONAL] Enable telemetry (disabled by default)" + .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") + ) + .addOption( + new Option( + "-q, --quiet", + "[OPTIONAL] Suppress startup banner for non-interactive use" + ).env("VAULTLINK_QUIET") + ) + .addOption( + new Option( + "--line-endings ", + "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" + ) + .default("auto") + .choices(["auto", "lf", "crlf"]) + .env("VAULTLINK_LINE_ENDINGS") ) .addHelpText( "after", @@ -67,9 +117,13 @@ export function parseArgs(argv: string[]): CliArgs { Examples: $ 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 \\ - --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 \\ - --log-level DEBUG + --log-level DEBUG --quiet + +Environment variables: + All options can be configured via VAULTLINK_ prefixed environment variables. + CLI arguments take precedence over environment variables. ` ); @@ -81,7 +135,6 @@ Examples: const remoteUri = opts.remoteUri as string | undefined; const token = opts.token as string | undefined; const vaultName = opts.vaultName as string | undefined; - const syncConcurrency = opts.syncConcurrency as number | undefined; const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; const ignorePattern = opts.ignorePattern as string[] | undefined; const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as @@ -90,22 +143,44 @@ Examples: const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const health = opts.health as string | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined; + const quiet = (opts.quiet as boolean | undefined) ?? false; + const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - if (localPath === undefined) { + const requireOption = ( + 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"); + const requiredVaultName = requireOption(vaultName, "vaultName"); + + // Validate remote URI protocol + if ( + !VALID_PROTOCOLS.some((prefix) => + requiredRemoteUri.startsWith(prefix) + ) + ) { throw new Error( - "required option '-l, --local-path ' not specified" + `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}` ); } - 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"); - } // Validate and parse log level const logLevelUpper = logLevelStr.toUpperCase(); @@ -120,17 +195,29 @@ Examples: } const logLevel = logLevelUpper; + const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; + const isLineEndingMode = (value: string): value is LineEndingMode => { + return validLineEndings.includes(value); + }; + if (!isLineEndingMode(lineEndingsStr)) { + throw new Error( + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` + ); + } + const lineEndings = lineEndingsStr; + return { - localPath, - remoteUri, - token, - vaultName, - syncConcurrency, + localPath: requiredLocalPath, + remoteUri: requiredRemoteUri, + token: requiredToken, + vaultName: requiredVaultName, maxFileSizeMB: maxFileSizeMb, ignorePatterns: ignorePattern, webSocketRetryIntervalMs: websocketRetryIntervalMs, logLevel, health, - enableTelemetry + enableTelemetry, + quiet, + lineEndings }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 48fd8954..1a8b1e83 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -5,24 +5,27 @@ import type { NetworkConnectionStatus } from "sync-client"; import { SyncClient, DEFAULT_SETTINGS, + Logger, LogLevel, + type LogLine, type SyncSettings, type StoredDatabase } from "sync-client"; import { parseArgs } from "./args"; import { NodeFileSystemOperations } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; -import { formatLogLine, colorize, styleText } from "./logger-formatter"; +import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; function writeHealthStatus( + logger: Logger, filePath: string, connectionStatus: NetworkConnectionStatus ): void { try { fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); } catch (error) { - console.error( + logger.error( `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` ); } @@ -35,12 +38,39 @@ const LOG_LEVEL_ORDER = { [LogLevel.ERROR]: 3 }; +function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { + return (logLine: LogLine): void => { + if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) { + // eslint-disable-next-line no-console + console.log(formatLogLine(logLine)); + } + }; +} + const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; +const PROGRESS_LOG_INTERVAL_MS = 2000; + +function resolveLineEndings( + mode: "auto" | "lf" | "crlf" +): string { + switch (mode) { + case "lf": + return "\n"; + case "crlf": + return "\r\n"; + case "auto": + return process.platform === "win32" ? "\r\n" : "\n"; + } +} async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); + const logger = new Logger(); + const logHandler = createLogHandler(args.logLevel); + logger.onLogEmitted.add(logHandler); + if (!fsSync.existsSync(absolutePath)) { fsSync.mkdirSync(absolutePath, { recursive: true }); } @@ -48,36 +78,27 @@ async function main(): Promise { try { const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { - console.error( - colorize(`Error: ${absolutePath} is not a directory`, "red") - ); + logger.error(`${absolutePath} is not a directory`); process.exit(1); } } catch (error) { - console.error( - colorize( - `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + logger.error( + `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } - console.log( - styleText("VaultLink Local CLI", "bold", "cyan") + - colorize(` v${packageJson.version}`, "dim") - ); - console.log(colorize("=".repeat(50), "dim")); - console.log( - `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` - ); - console.log( - `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` - ); - console.log( - `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` - ); - console.log(""); + 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}`); + if (args.lineEndings !== "auto") { + logger.info( + `Line endings: ${args.lineEndings.toUpperCase()}` + ); + } + } const dataDir = path.join(absolutePath, ".vaultlink"); const dataFile = path.join(dataDir, "sync-data.json"); @@ -97,8 +118,6 @@ async function main(): Promise { remoteUri: args.remoteUri, token: args.token, vaultName: args.vaultName, - syncConcurrency: - args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, ignorePatterns, webSocketRetryIntervalMs: @@ -119,11 +138,8 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - console.error( - colorize( - `Cannot read data file at ${dataFile}`, - "yellow" - ) + logger.warn( + `Cannot read data file at ${dataFile}` ); } @@ -133,23 +149,27 @@ async function main(): Promise { }; }, save: async ({ database: persistedDatabase }) => { - // settings can't be updated when running with this CLI await fs.writeFile( dataFile, JSON.stringify(persistedDatabase, null, 2) ); } }, - nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" + nativeLineEndings: resolveLineEndings(args.lineEndings) }); if (args.health !== undefined) { const healthFile = args.health; - const healthInterval = setInterval(() => { + const writeHealth = (): void => { void client.checkConnection().then((status) => { - writeHealthStatus(healthFile, status); + writeHealthStatus(client.logger, healthFile, status); }); - }, HEALTH_CHECK_INTERVAL_MS); + }; + writeHealth(); + const healthInterval = setInterval( + writeHealth, + HEALTH_CHECK_INTERVAL_MS + ); const clearHealthInterval = (): void => { clearInterval(healthInterval); }; @@ -158,17 +178,10 @@ async function main(): Promise { process.on("exit", clearHealthInterval); } - // Add colored log formatter with level filtering - client.logger.onLogEmitted.add((logLine) => { - // Only show messages at or above the configured log level - if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { - console.log(formatLogLine(logLine)); - } - }); - + client.logger.onLogEmitted.add(logHandler); 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; @@ -177,26 +190,56 @@ async function main(): Promise { ); }); + let syncBatchSize = 0; + let totalSyncOps = 0; + let lastProgressLogTime = 0; + client.onRemainingOperationsCountChanged.add((remaining) => { + if (remaining > syncBatchSize) { + syncBatchSize = remaining; + } + if (remaining === 0) { - client.logger.info("All sync operations completed"); + if (syncBatchSize > 0) { + totalSyncOps += syncBatchSize; + client.logger.info( + `Sync batch complete (${syncBatchSize} operations)` + ); + syncBatchSize = 0; + } } else { - client.logger.info(`${remaining} sync operations remaining`); + const now = Date.now(); + if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) { + client.logger.info( + `Syncing: ${remaining} operations remaining` + ); + lastProgressLogTime = now; + } } }); + let isShuttingDown = false; const gracefulShutdown = async (signal: string): Promise => { - console.log( - colorize( - `\n${signal} received. Shutting down gracefully...`, - "yellow" - ) + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + client.logger.info( + `${signal} received, shutting down gracefully` ); fileWatcher.stop(); await client.waitUntilFinished(); await client.destroy(); - console.log(colorize("Shutdown complete", "green")); + + if (totalSyncOps > 0) { + client.logger.info( + `Shutdown complete (${totalSyncOps} operations synced)` + ); + } else { + client.logger.info("Shutdown complete"); + } process.exit(0); }; @@ -210,27 +253,21 @@ async function main(): Promise { try { const connectionStatus = await client.checkConnection(); if (!connectionStatus.isSuccessful) { - console.error( - colorize( - `Error: Cannot connect to server: ${connectionStatus.serverMessage}`, - "red" - ) + client.logger.error( + `Cannot connect to server: ${connectionStatus.serverMessage}` ); process.exit(1); } - console.log(`${colorize("✓", "green")} Server connection successful`); - console.log(colorize("Press Ctrl+C to stop", "dim")); - console.log(""); + if (!args.quiet) { + client.logger.info("Server connection successful"); + } await client.start(); fileWatcher.start(); } catch (error) { - console.error( - colorize( - `Fatal error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + client.logger.error( + `Fatal error: ${error instanceof Error ? error.message : String(error)}` ); fileWatcher.stop(); @@ -240,11 +277,10 @@ async function main(): Promise { } main().catch((error: unknown) => { + // Last-resort handler before the logger exists + // eslint-disable-next-line no-console console.error( - colorize( - `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + `Unexpected error: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); }); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index e781d18f..16f397c5 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -1,15 +1,20 @@ import Watcher from "watcher"; import * as path from "path"; import type { SyncClient, RelativePath } from "sync-client"; +import { toUnixPath, matchesGlob } from "./path-utils"; export class FileWatcher { private watcher: Watcher | undefined; private isRunning = false; + private readonly ignorePatterns: string[]; public constructor( private readonly basePath: string, - private readonly client: SyncClient - ) {} + private readonly client: SyncClient, + ignorePatterns: string[] = [] + ) { + this.ignorePatterns = ignorePatterns; + } public start(): void { if (this.isRunning) { @@ -22,7 +27,9 @@ export class FileWatcher { recursive: true, renameDetection: true, renameTimeout: 125, - ignoreInitial: true + ignoreInitial: true, + ignore: (filePath: string): boolean => + this.shouldIgnore(filePath) }); this.watcher.on("add", (filePath: string) => { @@ -56,6 +63,11 @@ export class FileWatcher { this.client.logger.info("File watcher stopped"); } + private shouldIgnore(filePath: string): boolean { + const rel = toUnixPath(path.relative(this.basePath, filePath)); + return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern)); + } + private handleCreate(relativePath: RelativePath): void { this.client .syncLocallyCreatedFile(relativePath) @@ -101,18 +113,7 @@ export class FileWatcher { } private toRelativePath(absolutePath: string): RelativePath { - const relative = path.relative(this.basePath, absolutePath); - return this.toUnixPath(relative); - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; + return toUnixPath(path.relative(this.basePath, absolutePath)); } private formatError(err: unknown): string { diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts index 2dd9e721..d7211c88 100644 --- a/frontend/local-client-cli/src/healthcheck.ts +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable no-console */ /** * Healthcheck script for Docker container 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..f3078242 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { formatLogLine } from "./logger-formatter"; +import { LogLevel } from "sync-client"; + +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("\x1b[1m")); +}); + +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("\x1b[35m")); +}); + +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); + assert.ok(result.includes("\x1b[36m42\x1b[0m")); + assert.ok(!result.includes("\x1b[36m1\x1b[0m.")); +}); diff --git a/frontend/local-client-cli/src/logger-formatter.ts b/frontend/local-client-cli/src/logger-formatter.ts index 9f237103..b98415b6 100644 --- a/frontend/local-client-cli/src/logger-formatter.ts +++ b/frontend/local-client-cli/src/logger-formatter.ts @@ -1,36 +1,21 @@ import { LogLevel, type LogLine } from "sync-client"; -// ANSI color codes -export const colors = { +const colors = { reset: "\x1b[0m", bold: "\x1b[1m", - dim: "\x1b[2m", - // Foreground colors red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", - blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", gray: "\x1b[90m" } as const; -export function colorize(text: string, color: keyof typeof colors): string { +function colorize(text: string, color: keyof typeof colors): string { return `${colors[color]}${text}${colors.reset}`; } -/** - * Helper function to apply multiple color modifiers to text - */ -export function styleText( - text: string, - ...modifiers: (keyof typeof colors)[] -): string { - const prefix = modifiers.map((m) => colors[m]).join(""); - return `${prefix}${text}${colors.reset}`; -} - function formatTimestamp(date: Date): string { const [time] = date.toTimeString().split(" "); const ms = date.getMilliseconds().toString().padStart(3, "0"); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 3da8fc3a..f84cbdb8 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -6,6 +6,7 @@ import type { RelativePath, TextWithCursors } from "sync-client"; +import { toUnixPath } from "./path-utils"; export class NodeFileSystemOperations implements FileSystemOperations { public constructor(private readonly basePath: string) {} @@ -15,7 +16,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const files: RelativePath[] = []; await this.walkDirectory( - directory !== undefined ? this.toNativePath(directory) : "", + directory ?? "", files ); return files; @@ -24,7 +25,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async read(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { return await fs.readFile(fullPath); @@ -41,13 +42,13 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); const dir = path.dirname(fullPath); 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)}` @@ -61,13 +62,13 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); 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( @@ -79,7 +80,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async getFileSize(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { const stats = await fs.stat(fullPath); @@ -94,7 +95,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async exists(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.access(fullPath); @@ -107,7 +108,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async createDirectory(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.mkdir(fullPath, { recursive: false }); @@ -121,7 +122,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async delete(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.unlink(fullPath); @@ -136,14 +137,8 @@ export class NodeFileSystemOperations implements FileSystemOperations { oldPath: RelativePath, newPath: RelativePath ): Promise { - const oldFullPath = path.join( - this.basePath, - this.toNativePath(oldPath) - ); - const newFullPath = path.join( - this.basePath, - this.toNativePath(newPath) - ); + const oldFullPath = path.join(this.basePath, oldPath); + const newFullPath = path.join(this.basePath, newPath); const newDir = path.dirname(newFullPath); try { @@ -156,6 +151,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[] @@ -179,28 +187,9 @@ export class NodeFileSystemOperations implements FileSystemOperations { await this.walkDirectory(entryRelativePath, files); } else if (entry.isFile()) { // Always return forward slashes - files.push(this.toUnixPath(entryRelativePath)); + files.push(toUnixPath(entryRelativePath)); } } } - /** - * Convert a forward-slash path to native platform path separators - */ - private toNativePath(relativePath: string): string { - if (path.sep === "\\") { - return relativePath.replace(/\//g, "\\"); - } - return relativePath; - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } } diff --git a/frontend/local-client-cli/src/path-utils.test.ts b/frontend/local-client-cli/src/path-utils.test.ts new file mode 100644 index 00000000..13d33e6e --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { matchesGlob, toUnixPath } from "./path-utils"; + +test("matchesGlob - exact match", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("other", ".DS_Store"), false); +}); + +test("matchesGlob - dir/** matches directory and contents", () => { + assert.equal(matchesGlob(".git", ".git/**"), true); + assert.equal(matchesGlob(".git/config", ".git/**"), true); + assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true); + assert.equal(matchesGlob(".gitignore", ".git/**"), false); +}); + +test("matchesGlob - * matches within a single segment", () => { + assert.equal(matchesGlob("foo.tmp", "*.tmp"), true); + assert.equal(matchesGlob("bar.tmp", "*.tmp"), true); + assert.equal(matchesGlob("foo.md", "*.tmp"), false); + assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false); +}); + +test("matchesGlob - **/*.ext matches at any depth", () => { + assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("foo.md", "**/*.tmp"), false); +}); + +test("matchesGlob - ? matches single character", () => { + assert.equal(matchesGlob("a.md", "?.md"), true); + assert.equal(matchesGlob("ab.md", "?.md"), false); + assert.equal(matchesGlob(".md", "?.md"), false); +}); + +test("matchesGlob - dots are literal", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false); +}); + +test("matchesGlob - node_modules/** matches directory tree", () => { + assert.equal(matchesGlob("node_modules", "node_modules/**"), true); + assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true); + assert.equal( + matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"), + true + ); + assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false); +}); + +test("matchesGlob - **/ prefix matches zero or more segments", () => { + assert.equal(matchesGlob("test.log", "**/test.log"), true); + assert.equal(matchesGlob("dir/test.log", "**/test.log"), true); + assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true); +}); + +test("toUnixPath - forward slashes unchanged", () => { + assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz"); +}); diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts new file mode 100644 index 00000000..a781b746 --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.ts @@ -0,0 +1,18 @@ +import * as path from "path"; + +// Convert a native platform path to forward slashes (no-op on non-Windows) +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 +export function matchesGlob(filePath: string, pattern: string): boolean { + if ( + pattern.endsWith("/**") && + filePath === pattern.slice(0, -3) + ) { + return true; + } + return path.matchesGlob(filePath, pattern); +}