split: deterministic-tests, obsidian-plugin, local-cli, test-client, frontend root

New deterministic-tests workspace: scripted multi-client harness against
a real server (~110 scenario tests, server-control, managed-websocket,
test-runner). Updates to existing workspaces: obsidian-plugin (settings,
cursors, plugin entrypoint), local-client-cli (args, cli, file-watcher,
node-filesystem, path-utils + tests), test-client (mock-agent/client,
cli, error tracker). Bumps frontend root package.json/lock and adds
eslint config tweaks.
This commit is contained in:
Andras Schmelczer 2026-05-08 21:37:51 +01:00
parent 5a070340f1
commit 0daeaf6382
162 changed files with 10687 additions and 4051 deletions

View file

@ -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"

View file

@ -47,24 +47,25 @@ vaultlink \
### Required
| Option | Description |
|--------|-------------|
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
| Option | Description |
| ------------------------- | --------------------------------------------- |
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
### Optional
| Option | Default | Description |
|--------|---------|-------------|
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
| Option | Default | Description |
| ------------------------------------ | ------- | ----------------------------------------------- |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `--line-endings <mode>` | `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 .

View file

@ -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"
}
}

View file

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

View file

@ -1,21 +1,26 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import packageJson from "../package.json";
import { LogLevel } from "sync-client";
export interface CliArgs {
type LineEndingMode = "auto" | "lf" | "crlf";
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,83 @@ 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(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
)
.option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
.addOption(
new Option(
"--ignore-pattern <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 <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")
)
.addOption(
new Option(
"-q, --quiet",
"[OPTIONAL] Suppress startup banner for non-interactive use"
).env("VAULTLINK_QUIET")
)
.addOption(
new Option(
"--line-endings <mode>",
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
)
.default("auto")
.choices(["auto", "lf", "crlf"])
.env("VAULTLINK_LINE_ENDINGS")
)
.addHelpText(
"after",
@ -67,9 +114,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 +132,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 +140,39 @@ 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 = <T>(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 <path>' not specified"
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
);
}
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");
}
// Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase();
@ -120,17 +187,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
};
}

View file

@ -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,37 @@ 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<void> {
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 +76,25 @@ async function main(): Promise<void> {
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 +114,6 @@ async function main(): Promise<void> {
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
@ -119,12 +134,7 @@ async function main(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>;
} catch {
console.error(
colorize(
`Cannot read data file at ${dataFile}`,
"yellow"
)
);
logger.warn(`Cannot read data file at ${dataFile}`);
}
return {
@ -133,23 +143,27 @@ async function main(): Promise<void> {
};
},
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 +172,10 @@ async function main(): Promise<void> {
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 +184,54 @@ async function main(): Promise<void> {
);
});
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<void> => {
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 +245,21 @@ async function main(): Promise<void> {
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 +269,10 @@ async function main(): Promise<void> {
}
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);
});

View file

@ -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,8 @@ 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,66 +62,32 @@ 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)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyCreatedFile(relativePath);
}
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyUpdatedFile({ relativePath });
}
private handleDelete(relativePath: RelativePath): void {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyDeletedFile(relativePath);
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
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;
}
private formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
return toUnixPath(path.relative(this.basePath, absolutePath));
}
}

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Healthcheck script for Docker container

View file

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

View file

@ -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");

View file

@ -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) {}
@ -14,18 +15,12 @@ export class NodeFileSystemOperations implements FileSystemOperations {
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "",
files
);
await this.walkDirectory(directory ?? "", files);
return files;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, relativePath);
try {
return await fs.readFile(fullPath);
} catch (error) {
@ -39,15 +34,12 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, 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)}`
@ -59,15 +51,12 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, 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(
@ -77,10 +66,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, relativePath);
try {
const stats = await fs.stat(fullPath);
return stats.size;
@ -92,10 +78,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.access(fullPath);
return true;
@ -105,10 +88,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
@ -119,10 +99,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const fullPath = path.join(this.basePath, relativePath);
try {
await fs.unlink(fullPath);
} catch (error) {
@ -136,14 +113,8 @@ export class NodeFileSystemOperations implements FileSystemOperations {
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
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 +127,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[]
@ -179,28 +163,8 @@ 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;
}
}

View file

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

View file

@ -0,0 +1,15 @@
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);
}

View file

@ -18,7 +18,5 @@
"declarationMap": true,
"sourceMap": true
},
"exclude": [
"dist"
]
"exclude": ["dist"]
}

View file

@ -2,32 +2,32 @@ const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
};