diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index 7d785361..206e39b7 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import * as assert from "node:assert/strict"; import { parseArgs } from "./args"; +import { LogLevel } from "sync-client"; test("parseArgs - parse basic arguments", () => { const args = parseArgs([ @@ -134,3 +135,96 @@ test("parseArgs - throws on missing vault name", () => { ]); }, /--vault-name/); }); + +test("parseArgs - default log level is INFO", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + 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", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--log-level", + "ERROR" + ]); + + 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/); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 01e76fad..08ef2a6b 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import packageJson from "../package.json"; +import { LogLevel } from "sync-client"; export interface CliArgs { remoteUri: string; @@ -10,6 +11,7 @@ export interface CliArgs { maxFileSizeMB?: number; ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; + logLevel: LogLevel; } export function parseArgs(argv: string[]): CliArgs { @@ -21,20 +23,7 @@ export function parseArgs(argv: string[]): CliArgs { "VaultLink Local CLI - Sync your vault to the local filesystem" ) .version(packageJson.version) - .exitOverride((err) => { - // Let help and version exit normally - if ( - err.code === "commander.helpDisplayed" || - err.code === "commander.version" - ) { - process.exit(0); - } - throw err; - }) - .requiredOption( - "-l, --local-path ", - "Local directory path to sync" - ) + .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") @@ -57,6 +46,11 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] WebSocket retry interval in milliseconds", parseInt ) + .option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", + "INFO" + ) .addHelpText( "after", ` @@ -64,40 +58,65 @@ 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" + $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ + --log-level DEBUG ` ); program.parse(argv); - const options = program.opts<{ - localPath: string; - remoteUri?: string; - token?: string; - vaultName?: string; - syncConcurrency?: number; - maxFileSizeMb?: number; - ignorePattern?: string[]; - websocketRetryIntervalMs?: number; - }>(); + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const opts = program.opts(); + const localPath = opts.localPath as string | undefined; + 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 + | number + | undefined; + const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - if (options.remoteUri === undefined) { + 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 (options.token === undefined) { + if (token === undefined) { throw new Error("required option '--token ' not specified"); } - if (options.vaultName === undefined) { + if (vaultName === undefined) { throw new Error("required option '--vault-name ' not specified"); } + // Validate and parse log level + const logLevelUpper = logLevelStr.toUpperCase(); + const validLogLevels = Object.values(LogLevel); + const isLogLevel = (value: string): value is LogLevel => { + return (validLogLevels as readonly string[]).includes(value); + }; + if (!isLogLevel(logLevelUpper)) { + throw new Error( + `Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}` + ); + } + const logLevel = logLevelUpper; + return { - localPath: options.localPath, - remoteUri: options.remoteUri ?? "", - token: options.token ?? "", - vaultName: options.vaultName ?? "", - syncConcurrency: options.syncConcurrency, - maxFileSizeMB: options.maxFileSizeMb, - ignorePatterns: options.ignorePattern, - webSocketRetryIntervalMs: options.websocketRetryIntervalMs + localPath, + remoteUri, + token, + vaultName, + syncConcurrency, + maxFileSizeMB: maxFileSizeMb, + ignorePatterns: ignorePattern, + webSocketRetryIntervalMs: websocketRetryIntervalMs, + logLevel }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 8f0e367d..d453c551 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import { SyncClient, DEFAULT_SETTINGS, + LogLevel, type SyncSettings, type StoredDatabase } from "sync-client"; @@ -12,6 +13,13 @@ import { FileWatcher } from "./file-watcher"; import { formatLogLine, colorize, styleText } from "./logger-formatter"; import packageJson from "../package.json"; +const LOG_LEVEL_ORDER = { + [LogLevel.DEBUG]: 0, + [LogLevel.INFO]: 1, + [LogLevel.WARNING]: 2, + [LogLevel.ERROR]: 3 +}; + async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); @@ -34,7 +42,6 @@ async function main(): Promise { process.exit(1); } - // Print header with colors console.log( styleText("VaultLink Local CLI", "bold", "cyan") + colorize(` v${packageJson.version}`, "dim") @@ -112,9 +119,12 @@ async function main(): Promise { nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" }); - // Add colored log formatter + // Add colored log formatter with level filtering client.logger.addOnMessageListener((logLine) => { - console.log(formatLogLine(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.info("Starting sync client"); @@ -122,10 +132,7 @@ async function main(): Promise { const fileWatcher = new FileWatcher(absolutePath, client); client.addWebSocketStatusChangeListener(() => { - const currentSettings = client.getSettings(); - if (currentSettings.isSyncEnabled) { - client.logger.info("WebSocket status changed"); - } + client.logger.info("WebSocket status changed"); }); client.addRemainingSyncOperationsListener((remaining) => { @@ -143,6 +150,7 @@ async function main(): Promise { "yellow" ) ); + fileWatcher.stop(); await client.waitAndStop(); console.log(colorize("Shutdown complete", "green")); @@ -179,9 +187,9 @@ async function main(): Promise { console.log(colorize("─".repeat(50), "dim")); console.log(""); - await new Promise(() => { - // Keep process alive until signal received - }); + // await new Promise(() => { + + // }); } catch (error) { console.error( colorize( @@ -189,6 +197,7 @@ async function main(): Promise { "red" ) ); + fileWatcher.stop(); await client.waitAndStop(); process.exit(1);