asch/fix-everything #188
13 changed files with 683 additions and 224 deletions
Support env vars, line endings, add glob ignore patterns, clean up deps
commit
b83031e3e6
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:22-slim AS builder
|
FROM node:25-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ COPY . .
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:25-alpine
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
||||||
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ vaultlink \
|
||||||
### Required
|
### Required
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
| ------------------------- | --------------------------------------------- |
|
||||||
| `-l, --local-path <path>` | Local directory to sync |
|
| `-l, --local-path <path>` | Local directory to sync |
|
||||||
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||||
| `-t, --token <token>` | Authentication token |
|
| `-t, --token <token>` | Authentication token |
|
||||||
|
|
@ -57,12 +57,13 @@ vaultlink \
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ------------------------------------ | ------- | ----------------------------------------------- |
|
||||||
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
|
||||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
| `--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 |
|
| `-h, --help` | - | Show help |
|
||||||
| `-V, --version` | - | Show version |
|
| `-V, --version` | - | Show version |
|
||||||
|
|
||||||
|
|
@ -74,22 +75,32 @@ vaultlink \
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Basic usage:
|
Basic usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
||||||
```
|
```
|
||||||
|
|
||||||
With ignore patterns:
|
With ignore patterns:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
--ignore-pattern "*.tmp" \
|
--ignore-pattern "**/*.tmp" \
|
||||||
--ignore-pattern ".DS_Store" \
|
--ignore-pattern ".DS_Store" \
|
||||||
--ignore-pattern "node_modules/**"
|
--ignore-pattern "node_modules/**"
|
||||||
```
|
```
|
||||||
|
|
||||||
With debug logging:
|
With debug logging and quiet startup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
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
|
## Docker Deployment
|
||||||
|
|
@ -176,6 +187,7 @@ services:
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
# or from the parent folder, run
|
# or from the parent folder, run
|
||||||
|
|
@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile .
|
||||||
```
|
```
|
||||||
|
|
||||||
Test:
|
Test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker build:
|
Docker build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,16 @@
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test 'src/**/*.test.ts'"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"commander": "^14.0.2",
|
|
||||||
"watcher": "^2.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"commander": "^14.0.2",
|
||||||
|
"watcher": "^2.3.1",
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.4",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"webpack": "^5.99.9",
|
"webpack": "^5.103.0",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack-cli": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => {
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--sync-concurrency",
|
|
||||||
"5",
|
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.syncConcurrency, 5);
|
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,3 +225,226 @@ test("parseArgs - throws on invalid log level", () => {
|
||||||
]);
|
]);
|
||||||
}, /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");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
|
export type LineEndingMode = "auto" | "lf" | "crlf";
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
syncConcurrency?: number;
|
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
|
quiet: boolean;
|
||||||
|
lineEndings: LineEndingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -25,41 +30,86 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
.addOption(
|
||||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
new Option(
|
||||||
.option("-t, --token <token>", "Authentication token")
|
"-l, --local-path <path>",
|
||||||
.option("-v, --vault-name <name>", "Vault name")
|
"Local directory path to sync"
|
||||||
.option(
|
).env("VAULTLINK_LOCAL_PATH")
|
||||||
"--sync-concurrency <number>",
|
|
||||||
"[OPTIONAL] Number of concurrent sync operations",
|
|
||||||
parseInt
|
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"-r, --remote-uri <uri>",
|
||||||
|
"Remote server URI"
|
||||||
|
).env("VAULTLINK_REMOTE_URI")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"-t, --token <token>",
|
||||||
|
"Authentication token"
|
||||||
|
).env("VAULTLINK_TOKEN")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"-v, --vault-name <name>",
|
||||||
|
"Vault name"
|
||||||
|
).env("VAULTLINK_VAULT_NAME")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--max-file-size-mb <number>",
|
"--max-file-size-mb <number>",
|
||||||
"[OPTIONAL] Maximum file size in MB",
|
"[OPTIONAL] Maximum file size in MB"
|
||||||
parseInt
|
|
||||||
)
|
)
|
||||||
.option(
|
.argParser(parseInt)
|
||||||
|
.env("VAULTLINK_MAX_FILE_SIZE_MB")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--ignore-pattern <pattern...>",
|
"--ignore-pattern <pattern...>",
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
|
).env("VAULTLINK_IGNORE_PATTERNS")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--websocket-retry-interval-ms <number>",
|
"--websocket-retry-interval-ms <number>",
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
"[OPTIONAL] WebSocket retry interval in milliseconds"
|
||||||
parseInt
|
|
||||||
)
|
)
|
||||||
.option(
|
.argParser(parseInt)
|
||||||
|
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--log-level <level>",
|
"--log-level <level>",
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
|
||||||
"INFO"
|
|
||||||
)
|
)
|
||||||
.option(
|
.default("INFO")
|
||||||
|
.env("VAULTLINK_LOG_LEVEL")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--health <path>",
|
"--health <path>",
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
|
).env("VAULTLINK_HEALTH")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
|
new Option(
|
||||||
"--enable-telemetry",
|
"--enable-telemetry",
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
"[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(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
|
|
@ -67,9 +117,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
Examples:
|
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
|
||||||
$ 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 \\
|
$ 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 +135,6 @@ Examples:
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName 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 maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
|
|
@ -90,21 +143,43 @@ Examples:
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | 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 */
|
/* 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(
|
throw new Error(
|
||||||
"required option '-l, --local-path <path>' not specified"
|
`required option '${option?.flags ?? name}' not specified${envHint}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (remoteUri === undefined) {
|
return value;
|
||||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
};
|
||||||
}
|
|
||||||
if (token === undefined) {
|
const requiredLocalPath = requireOption(localPath, "localPath");
|
||||||
throw new Error("required option '--token <token>' not specified");
|
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
||||||
}
|
const requiredToken = requireOption(token, "token");
|
||||||
if (vaultName === undefined) {
|
const requiredVaultName = requireOption(vaultName, "vaultName");
|
||||||
throw new Error("required option '--vault-name <name>' not specified");
|
|
||||||
|
// Validate remote URI protocol
|
||||||
|
if (
|
||||||
|
!VALID_PROTOCOLS.some((prefix) =>
|
||||||
|
requiredRemoteUri.startsWith(prefix)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
|
|
@ -120,17 +195,29 @@ Examples:
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
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 {
|
return {
|
||||||
localPath,
|
localPath: requiredLocalPath,
|
||||||
remoteUri,
|
remoteUri: requiredRemoteUri,
|
||||||
token,
|
token: requiredToken,
|
||||||
vaultName,
|
vaultName: requiredVaultName,
|
||||||
syncConcurrency,
|
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry
|
enableTelemetry,
|
||||||
|
quiet,
|
||||||
|
lineEndings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,27 @@ import type { NetworkConnectionStatus } from "sync-client";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
Logger,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
type LogLine,
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs } from "./args";
|
import { parseArgs } from "./args";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
import { FileWatcher } from "./file-watcher";
|
import { FileWatcher } from "./file-watcher";
|
||||||
import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
import { formatLogLine } from "./logger-formatter";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
function writeHealthStatus(
|
function writeHealthStatus(
|
||||||
|
logger: Logger,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
connectionStatus: NetworkConnectionStatus
|
connectionStatus: NetworkConnectionStatus
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -35,12 +38,39 @@ const LOG_LEVEL_ORDER = {
|
||||||
[LogLevel.ERROR]: 3
|
[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 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> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
const absolutePath = path.resolve(args.localPath);
|
const absolutePath = path.resolve(args.localPath);
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
const logHandler = createLogHandler(args.logLevel);
|
||||||
|
logger.onLogEmitted.add(logHandler);
|
||||||
|
|
||||||
if (!fsSync.existsSync(absolutePath)) {
|
if (!fsSync.existsSync(absolutePath)) {
|
||||||
fsSync.mkdirSync(absolutePath, { recursive: true });
|
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
@ -48,36 +78,27 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
console.error(
|
logger.error(`${absolutePath} is not a directory`);
|
||||||
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
colorize(
|
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
if (!args.quiet) {
|
||||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
logger.info(`VaultLink Local CLI v${packageJson.version}`);
|
||||||
colorize(` v${packageJson.version}`, "dim")
|
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()}`
|
||||||
);
|
);
|
||||||
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("");
|
|
||||||
|
|
||||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
@ -97,8 +118,6 @@ async function main(): Promise<void> {
|
||||||
remoteUri: args.remoteUri,
|
remoteUri: args.remoteUri,
|
||||||
token: args.token,
|
token: args.token,
|
||||||
vaultName: args.vaultName,
|
vaultName: args.vaultName,
|
||||||
syncConcurrency:
|
|
||||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
|
||||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
ignorePatterns,
|
ignorePatterns,
|
||||||
webSocketRetryIntervalMs:
|
webSocketRetryIntervalMs:
|
||||||
|
|
@ -119,11 +138,8 @@ async function main(): Promise<void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
logger.warn(
|
||||||
colorize(
|
`Cannot read data file at ${dataFile}`
|
||||||
`Cannot read data file at ${dataFile}`,
|
|
||||||
"yellow"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,23 +149,27 @@ async function main(): Promise<void> {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
save: async ({ database: persistedDatabase }) => {
|
save: async ({ database: persistedDatabase }) => {
|
||||||
// settings can't be updated when running with this CLI
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
dataFile,
|
dataFile,
|
||||||
JSON.stringify(persistedDatabase, null, 2)
|
JSON.stringify(persistedDatabase, null, 2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
nativeLineEndings: resolveLineEndings(args.lineEndings)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.health !== undefined) {
|
if (args.health !== undefined) {
|
||||||
const healthFile = args.health;
|
const healthFile = args.health;
|
||||||
const healthInterval = setInterval(() => {
|
const writeHealth = (): void => {
|
||||||
void client.checkConnection().then((status) => {
|
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 => {
|
const clearHealthInterval = (): void => {
|
||||||
clearInterval(healthInterval);
|
clearInterval(healthInterval);
|
||||||
};
|
};
|
||||||
|
|
@ -158,17 +178,10 @@ async function main(): Promise<void> {
|
||||||
process.on("exit", clearHealthInterval);
|
process.on("exit", clearHealthInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add colored log formatter with level filtering
|
client.logger.onLogEmitted.add(logHandler);
|
||||||
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.info("Starting sync client");
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
|
||||||
|
|
||||||
client.onWebSocketStatusChanged.add(() => {
|
client.onWebSocketStatusChanged.add(() => {
|
||||||
const isConnected = client.isWebSocketConnected;
|
const isConnected = client.isWebSocketConnected;
|
||||||
|
|
@ -177,26 +190,56 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let syncBatchSize = 0;
|
||||||
|
let totalSyncOps = 0;
|
||||||
|
let lastProgressLogTime = 0;
|
||||||
|
|
||||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||||
|
if (remaining > syncBatchSize) {
|
||||||
|
syncBatchSize = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
if (remaining === 0) {
|
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 {
|
} 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> => {
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
console.log(
|
if (isShuttingDown) {
|
||||||
colorize(
|
return;
|
||||||
`\n${signal} received. Shutting down gracefully...`,
|
}
|
||||||
"yellow"
|
isShuttingDown = true;
|
||||||
)
|
|
||||||
|
client.logger.info(
|
||||||
|
`${signal} received, shutting down gracefully`
|
||||||
);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
await client.waitUntilFinished();
|
await client.waitUntilFinished();
|
||||||
await client.destroy();
|
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);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -210,27 +253,21 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const connectionStatus = await client.checkConnection();
|
const connectionStatus = await client.checkConnection();
|
||||||
if (!connectionStatus.isSuccessful) {
|
if (!connectionStatus.isSuccessful) {
|
||||||
console.error(
|
client.logger.error(
|
||||||
colorize(
|
`Cannot connect to server: ${connectionStatus.serverMessage}`
|
||||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
if (!args.quiet) {
|
||||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
client.logger.info("Server connection successful");
|
||||||
console.log("");
|
}
|
||||||
|
|
||||||
await client.start();
|
await client.start();
|
||||||
fileWatcher.start();
|
fileWatcher.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
client.logger.error(
|
||||||
colorize(
|
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
|
|
@ -240,11 +277,10 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
|
// Last-resort handler before the logger exists
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
"red"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import Watcher from "watcher";
|
import Watcher from "watcher";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { SyncClient, RelativePath } from "sync-client";
|
import type { SyncClient, RelativePath } from "sync-client";
|
||||||
|
import { toUnixPath, matchesGlob } from "./path-utils";
|
||||||
|
|
||||||
export class FileWatcher {
|
export class FileWatcher {
|
||||||
private watcher: Watcher | undefined;
|
private watcher: Watcher | undefined;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
private readonly ignorePatterns: string[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
private readonly client: SyncClient
|
private readonly client: SyncClient,
|
||||||
) {}
|
ignorePatterns: string[] = []
|
||||||
|
) {
|
||||||
|
this.ignorePatterns = ignorePatterns;
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
|
|
@ -22,7 +27,9 @@ export class FileWatcher {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
renameDetection: true,
|
renameDetection: true,
|
||||||
renameTimeout: 125,
|
renameTimeout: 125,
|
||||||
ignoreInitial: true
|
ignoreInitial: true,
|
||||||
|
ignore: (filePath: string): boolean =>
|
||||||
|
this.shouldIgnore(filePath)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watcher.on("add", (filePath: string) => {
|
this.watcher.on("add", (filePath: string) => {
|
||||||
|
|
@ -56,6 +63,11 @@ export class FileWatcher {
|
||||||
this.client.logger.info("File watcher stopped");
|
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 {
|
private handleCreate(relativePath: RelativePath): void {
|
||||||
this.client
|
this.client
|
||||||
.syncLocallyCreatedFile(relativePath)
|
.syncLocallyCreatedFile(relativePath)
|
||||||
|
|
@ -101,18 +113,7 @@ export class FileWatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRelativePath(absolutePath: string): RelativePath {
|
private toRelativePath(absolutePath: string): RelativePath {
|
||||||
const relative = path.relative(this.basePath, absolutePath);
|
return toUnixPath(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 {
|
private formatError(err: unknown): string {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Healthcheck script for Docker container
|
* Healthcheck script for Docker container
|
||||||
|
|
|
||||||
50
frontend/local-client-cli/src/logger-formatter.test.ts
Normal file
50
frontend/local-client-cli/src/logger-formatter.test.ts
Normal 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."));
|
||||||
|
});
|
||||||
|
|
@ -1,36 +1,21 @@
|
||||||
import { LogLevel, type LogLine } from "sync-client";
|
import { LogLevel, type LogLine } from "sync-client";
|
||||||
|
|
||||||
// ANSI color codes
|
const colors = {
|
||||||
export const colors = {
|
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
bold: "\x1b[1m",
|
bold: "\x1b[1m",
|
||||||
dim: "\x1b[2m",
|
|
||||||
|
|
||||||
// Foreground colors
|
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
blue: "\x1b[34m",
|
|
||||||
magenta: "\x1b[35m",
|
magenta: "\x1b[35m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
gray: "\x1b[90m"
|
gray: "\x1b[90m"
|
||||||
} as const;
|
} 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}`;
|
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 {
|
function formatTimestamp(date: Date): string {
|
||||||
const [time] = date.toTimeString().split(" ");
|
const [time] = date.toTimeString().split(" ");
|
||||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
import { toUnixPath } from "./path-utils";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
@ -15,7 +16,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(
|
await this.walkDirectory(
|
||||||
directory !== undefined ? this.toNativePath(directory) : "",
|
directory ?? "",
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
return files;
|
return files;
|
||||||
|
|
@ -24,7 +25,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
|
|
@ -41,13 +42,13 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(fullPath, content);
|
await this.atomicWrite(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
|
@ -61,13 +62,13 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
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;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -79,7 +80,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
|
|
@ -94,7 +95,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
|
|
@ -107,7 +108,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
|
|
@ -121,7 +122,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
relativePath
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
|
|
@ -136,14 +137,8 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(
|
const oldFullPath = path.join(this.basePath, oldPath);
|
||||||
this.basePath,
|
const newFullPath = path.join(this.basePath, newPath);
|
||||||
this.toNativePath(oldPath)
|
|
||||||
);
|
|
||||||
const newFullPath = path.join(
|
|
||||||
this.basePath,
|
|
||||||
this.toNativePath(newPath)
|
|
||||||
);
|
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -156,6 +151,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(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
|
|
@ -179,28 +187,9 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
frontend/local-client-cli/src/path-utils.test.ts
Normal file
60
frontend/local-client-cli/src/path-utils.test.ts
Normal 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");
|
||||||
|
});
|
||||||
18
frontend/local-client-cli/src/path-utils.ts
Normal file
18
frontend/local-client-cli/src/path-utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue