Improve local client
This commit is contained in:
parent
a75b3469a3
commit
bbec7f14dd
6 changed files with 276 additions and 55 deletions
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
70
frontend/local-client-cli/src/logger-formatter.test.ts
Normal file
70
frontend/local-client-cli/src/logger-formatter.test.ts
Normal 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}.`));
|
||||
});
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue