Add local CLI #144

Merged
schmelczer merged 16 commits from asch/local-cli into main 2025-10-21 22:45:47 +01:00
3 changed files with 167 additions and 45 deletions
Showing only changes of commit 70c7bdcade - Show all commits

View file

@ -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/);
});

View file

@ -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 <path>",
"Local directory path to sync"
)
.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")
@ -57,6 +46,11 @@ export function parseArgs(argv: string[]): CliArgs {
"[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt
)
.option(
"--log-level <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 <path>' not specified"
);
}
if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (options.token === undefined) {
if (token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (options.vaultName === undefined) {
if (vaultName === undefined) {
throw new Error("required option '--vault-name <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
};
}

View file

@ -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<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
@ -34,7 +42,6 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
"yellow"
)
);
fileWatcher.stop();
await client.waitAndStop();
console.log(colorize("Shutdown complete", "green"));
@ -179,9 +187,9 @@ async function main(): Promise<void> {
console.log(colorize("─".repeat(50), "dim"));
console.log("");
await new Promise<void>(() => {
// Keep process alive until signal received
});
// await new Promise<void>(() => {
// });
} catch (error) {
console.error(
colorize(
@ -189,6 +197,7 @@ async function main(): Promise<void> {
"red"
)
);
fileWatcher.stop();
await client.waitAndStop();
process.exit(1);