vault-link/frontend/local-client-cli/src/cli.ts
Andras Schmelczer 96d720eed9
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
simplify 3
2026-09-22 08:00:29 +01:00

240 lines
7.2 KiB
TypeScript

import * as path from "path";
import * as fs from "fs/promises";
import * as fsSync from "fs";
import type { NetworkConnectionStatus, Logger } from "sync-client";
import {
SyncClient,
DEFAULT_SETTINGS,
LogLevel,
LogLine,
type SyncSettings
} from "sync-client";
import { parseArgs } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { FileWatcher } from "./file-watcher";
import { ClientPersistence } from "./client-persistence";
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) {
logger.error(
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3
};
function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
return (logLine: LogLine): void => {
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) {
console.log(formatLogLine(logLine));
}
};
}
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
const PROGRESS_LOG_INTERVAL_MS = 2000;
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
const logHandler = createLogHandler(args.logLevel);
// Boot-time messages are emitted directly through logHandler before the
// SyncClient (and its Logger) exist; afterwards every log line flows
// through client.logger.
const emitBoot = (level: LogLevel, message: string): void => {
logHandler(new LogLine(level, message));
};
if (!fsSync.existsSync(absolutePath)) {
fsSync.mkdirSync(absolutePath, { recursive: true });
}
try {
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
process.exit(1);
}
} catch (error) {
emitBoot(
LogLevel.ERROR,
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
if (!args.quiet) {
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
}
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true });
const fileSystem = new NodeFileSystemOperations(absolutePath);
const ignorePatterns = [
...(args.ignorePatterns ?? []),
`${VAULTLINK_DIR}/**`
];
const settings: SyncSettings = {
...DEFAULT_SETTINGS,
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true,
enableTelemetry:
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
};
const client = await SyncClient.create({
fs: fileSystem,
persistence: new ClientPersistence(dataFile, settings)
});
if (args.health !== undefined) {
const healthFile = args.health;
const writeHealth = (): void => {
void client.checkConnection().then((status) => {
writeHealthStatus(client.logger, healthFile, status);
});
};
writeHealth();
const healthInterval = setInterval(
writeHealth,
HEALTH_CHECK_INTERVAL_MS
);
const clearHealthInterval = (): void => {
clearInterval(healthInterval);
};
process.on("SIGINT", clearHealthInterval);
process.on("SIGTERM", clearHealthInterval);
process.on("exit", clearHealthInterval);
}
client.logger.onLogEmitted.add(logHandler);
client.logger.info("Starting sync client");
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
client.onWebSocketStatusChanged.add(() => {
const isConnected = client.isWebSocketConnected;
client.logger.info(
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
);
});
let syncBatchSize = 0;
let totalSyncOps = 0;
let lastProgressLogTime = 0;
client.onRemainingOperationsCountChanged.add((remaining) => {
if (remaining > syncBatchSize) {
syncBatchSize = remaining;
}
if (remaining === 0) {
if (syncBatchSize > 0) {
totalSyncOps += syncBatchSize;
client.logger.info(
`Sync batch complete (${syncBatchSize} operations)`
);
syncBatchSize = 0;
}
} else {
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<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
client.logger.info(`${signal} received, shutting down gracefully`);
fileWatcher.stop();
await client.destroy();
if (totalSyncOps > 0) {
client.logger.info(
`Shutdown complete (${totalSyncOps} operations synced)`
);
} else {
client.logger.info("Shutdown complete");
}
process.exit(0);
};
process.on("SIGINT", () => {
void gracefulShutdown("SIGINT");
});
process.on("SIGTERM", () => {
void gracefulShutdown("SIGTERM");
});
try {
const connectionStatus = await client.checkConnection();
if (!connectionStatus.isSuccessful) {
client.logger.error(
`Cannot connect to server: ${connectionStatus.serverMessage}`
);
process.exit(1);
}
if (!args.quiet) {
client.logger.info("Server connection successful");
}
await client.start();
fileWatcher.start();
} catch (error) {
client.logger.error(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
);
fileWatcher.stop();
await client.destroy();
process.exit(1);
}
}
main().catch((error: unknown) => {
console.error(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
});