Update local-client-cli and obsidian-plugin to use newer API #194
24 changed files with 741 additions and 436 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ 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 |
|
||||
|
|
@ -57,12 +57,13 @@ vaultlink \
|
|||
### 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 |
|
||||
| `--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 |
|
||||
|
||||
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
.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>",
|
||||
"[OPTIONAL] Maximum file size in MB",
|
||||
parseInt
|
||||
"[OPTIONAL] Maximum file size in MB"
|
||||
)
|
||||
.option(
|
||||
.argParser(parseInt)
|
||||
.env("VAULTLINK_MAX_FILE_SIZE_MB")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--ignore-pattern <pattern...>",
|
||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||
).env("VAULTLINK_IGNORE_PATTERNS")
|
||||
)
|
||||
.option(
|
||||
.addOption(
|
||||
new Option(
|
||||
"--websocket-retry-interval-ms <number>",
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||
parseInt
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds"
|
||||
)
|
||||
.option(
|
||||
.argParser(parseInt)
|
||||
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--log-level <level>",
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||
"INFO"
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
|
||||
)
|
||||
.option(
|
||||
.default("INFO")
|
||||
.env("VAULTLINK_LOG_LEVEL")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--health <path>",
|
||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||
).env("VAULTLINK_HEALTH")
|
||||
)
|
||||
.option(
|
||||
.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,21 +140,38 @@ 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 '-l, --local-path <path>' not specified"
|
||||
`required option '${option?.flags ?? name}' not specified${envHint}`
|
||||
);
|
||||
}
|
||||
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");
|
||||
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(
|
||||
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and parse log level
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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");
|
||||
});
|
||||
15
frontend/local-client-cli/src/path-utils.ts
Normal file
15
frontend/local-client-cli/src/path-utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -18,7 +18,5 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
|
|||
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
|
|
@ -57,31 +58,6 @@ Quick starting guide for new plugin devs:
|
|||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
|
||||
|
||||
## Funding URL
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
```
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
|
|
|
|||
|
|
@ -13,25 +13,25 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/node": "^25.0.2",
|
||||
"css-loader": "^7.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"obsidian": "1.10.2",
|
||||
"reconcile-text": "^0.8.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"obsidian": "1.11.0",
|
||||
"reconcile-text": "^0.11.0",
|
||||
"resolve-url-loader": "^5.0.0",
|
||||
"sass": "^1.91.0",
|
||||
"sass": "^1.96.0",
|
||||
"sass-loader": "^16.0.6",
|
||||
"sync-client": "file:../sync-client",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-loader": "^9.5.2",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"ts-loader": "^9.5.4",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"url": "^0.11.4",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack": "^5.103.0",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
});
|
||||
|
||||
if (IS_DEBUG_BUILD) {
|
||||
debugging.logToConsole(client);
|
||||
debugging.logToConsole(client.logger);
|
||||
}
|
||||
|
||||
return client;
|
||||
|
|
@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
),
|
||||
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||
this.app.vault.on("create", (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyCreatedFile(file.path);
|
||||
client.syncLocallyCreatedFile(file.path);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||
|
|
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||
await client.syncLocallyDeletedFile(file.path);
|
||||
this.app.vault.on("delete", (file: TAbstractFile) => {
|
||||
client.syncLocallyDeletedFile(file.path);
|
||||
}),
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
(file: TAbstractFile, oldPath: string) => {
|
||||
if (file instanceof TFile) {
|
||||
await client.syncLocallyUpdatedFile({
|
||||
client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: file.path
|
||||
});
|
||||
|
|
@ -267,13 +267,11 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||
this.rateLimitedUpdatesPerFile.set(
|
||||
path,
|
||||
rateLimit(
|
||||
async () =>
|
||||
rateLimit(async () => {
|
||||
client.syncLocallyUpdatedFile({
|
||||
relativePath: path
|
||||
}),
|
||||
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||
)
|
||||
});
|
||||
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
|
||||
);
|
||||
}
|
||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ export function renderCursorsInFileExplorer(
|
|||
app: App
|
||||
): void {
|
||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||
if (fileExplorers.length == 0) return;
|
||||
if (fileExplorers.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [fileExplorer] = fileExplorers;
|
||||
|
||||
|
|
@ -34,7 +36,7 @@ export function renderCursorsInFileExplorer(
|
|||
(parent) => {
|
||||
cursors.forEach((cursor) => {
|
||||
cursor.documentsWithCursors.forEach((document) => {
|
||||
if (document.relative_path.startsWith(key)) {
|
||||
if (document.relativePath.startsWith(key)) {
|
||||
parent.appendChild(
|
||||
createSpan({
|
||||
text: cursor.userName,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
|||
return clientCursors.flatMap((cursor) =>
|
||||
cursor.cursors.map((span) => ({
|
||||
name: client.userName,
|
||||
path: cursor.relative_path,
|
||||
path: cursor.relativePath,
|
||||
deviceId: client.deviceId,
|
||||
isOutdated: client.isOutdated,
|
||||
span: { ...span }
|
||||
|
|
@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
|||
]
|
||||
)
|
||||
},
|
||||
edited
|
||||
edited,
|
||||
"Markdown"
|
||||
);
|
||||
|
||||
reconciled.cursors.forEach(({ id, position }) => {
|
||||
|
|
|
|||
|
|
@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
|
||||
new Notice("Checking connection to the server...");
|
||||
new Notice(
|
||||
(
|
||||
await this.syncClient.checkConnection()
|
||||
).serverMessage
|
||||
(await this.syncClient.checkConnection())
|
||||
.serverMessage
|
||||
);
|
||||
await this.statusDescription.updateConnectionState();
|
||||
} else {
|
||||
|
|
@ -351,22 +350,6 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync concurrency")
|
||||
.setDesc(
|
||||
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
||||
)
|
||||
.addSlider((text) =>
|
||||
text
|
||||
.setLimits(1, 16, 1)
|
||||
.setDynamicTooltip()
|
||||
.setInstant(false)
|
||||
.setValue(this.syncClient.getSettings().syncConcurrency)
|
||||
.onChange(async (value) =>
|
||||
this.syncClient.setSetting("syncConcurrency", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Maximum file size to be uploaded (MB)")
|
||||
.setDesc(
|
||||
|
|
@ -484,40 +467,6 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Minimum save interval (ms)")
|
||||
.setDesc(
|
||||
"The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance."
|
||||
)
|
||||
.addText((input) =>
|
||||
input
|
||||
.setValue(
|
||||
this.syncClient
|
||||
.getSettings()
|
||||
.minimumSaveIntervalMs.toString()
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (value === "") {
|
||||
return;
|
||||
}
|
||||
let parsedValue = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
parsedValue =
|
||||
this.syncClient.getSettings()
|
||||
.minimumSaveIntervalMs;
|
||||
}
|
||||
|
||||
if (value !== parsedValue.toString()) {
|
||||
input.setValue(parsedValue.toString());
|
||||
}
|
||||
|
||||
return this.syncClient.setSetting(
|
||||
"minimumSaveIntervalMs",
|
||||
parsedValue
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private setStatusDescriptionSubscription(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export class StatusDescription {
|
|||
text: ` and has indexed approximately `
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.syncClient.documentCount}`,
|
||||
text: `${this.syncClient.syncedDocumentCount}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
|
|
|
|||
|
|
@ -6,12 +6,7 @@
|
|||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
]
|
||||
"lib": ["DOM", "ES2024"]
|
||||
},
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ module.exports = (env, argv) => ({
|
|||
const source = path.resolve(__dirname, "dist");
|
||||
const destinations = [
|
||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||
];
|
||||
destinations.forEach((destination) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue