Update local-client-cli and obsidian-plugin to use newer API #194

Merged
andras merged 4 commits from asch/cli-and-plugin-updates into main 2026-05-09 14:48:37 +01:00
24 changed files with 741 additions and 436 deletions
Showing only changes of commit 682dc74497 - Show all commits

View file

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

View file

@ -47,24 +47,25 @@ 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 |
| `-v, --vault-name <name>` | Vault name on server | | `-v, --vault-name <name>` | Vault name on server |
### 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 |
| `-h, --help` | - | Show help | | `-q, --quiet` | - | Suppress startup banner for non-interactive use |
| `-V, --version` | - | Show version | | `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
### Auto-Ignored Patterns ### Auto-Ignored Patterns
@ -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 .

View file

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

View file

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

View file

@ -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 interface CliArgs { type LineEndingMode = "auto" | "lf" | "crlf";
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,83 @@ 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(
"--max-file-size-mb <number>", new Option("-r, --remote-uri <uri>", "Remote server URI").env(
"[OPTIONAL] Maximum file size in MB", "VAULTLINK_REMOTE_URI"
parseInt )
) )
.option( .addOption(
"--ignore-pattern <pattern...>", new Option("-t, --token <token>", "Authentication token").env(
"[OPTIONAL] Patterns to ignore (can be specified multiple times)" "VAULTLINK_TOKEN"
)
) )
.option( .addOption(
"--websocket-retry-interval-ms <number>", new Option("-v, --vault-name <name>", "Vault name").env(
"[OPTIONAL] WebSocket retry interval in milliseconds", "VAULTLINK_VAULT_NAME"
parseInt )
) )
.option( .addOption(
"--log-level <level>", new Option(
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "--max-file-size-mb <number>",
"INFO" "[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
) )
.option( .addOption(
"--health <path>", new Option(
"[OPTIONAL] Path to health status file for Docker healthcheck" "--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
).env("VAULTLINK_IGNORE_PATTERNS")
) )
.option( .addOption(
"--enable-telemetry", new Option(
"[OPTIONAL] Enable telemetry (disabled by default)" "--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds"
)
.argParser(parseInt)
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
)
.addOption(
new Option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
)
.default("INFO")
.env("VAULTLINK_LOG_LEVEL")
)
.addOption(
new Option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
).env("VAULTLINK_HEALTH")
)
.addOption(
new Option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
).env("VAULTLINK_ENABLE_TELEMETRY")
)
.addOption(
new Option(
"-q, --quiet",
"[OPTIONAL] Suppress startup banner for non-interactive use"
).env("VAULTLINK_QUIET")
)
.addOption(
new Option(
"--line-endings <mode>",
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
)
.default("auto")
.choices(["auto", "lf", "crlf"])
.env("VAULTLINK_LINE_ENDINGS")
) )
.addHelpText( .addHelpText(
"after", "after",
@ -67,9 +114,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 +132,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,22 +140,39 @@ 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(
`required option '${option?.flags ?? name}' not specified${envHint}`
);
}
return value;
};
const requiredLocalPath = requireOption(localPath, "localPath");
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token");
const requiredVaultName = requireOption(vaultName, "vaultName");
// Validate remote URI protocol
if (
!VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix))
) {
throw new Error( throw new Error(
"required option '-l, --local-path <path>' not specified" `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
); );
} }
if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified");
}
// Validate and parse log level // Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase(); const logLevelUpper = logLevelStr.toUpperCase();
@ -120,17 +187,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
}; };
} }

View file

@ -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,37 @@ 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 +76,25 @@ 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}`);
console.log(colorize("=".repeat(50), "dim")); logger.info(`Vault name: ${args.vaultName}`);
console.log( if (args.lineEndings !== "auto") {
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`);
); }
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 +114,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,12 +134,7 @@ 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(`Cannot read data file at ${dataFile}`);
colorize(
`Cannot read data file at ${dataFile}`,
"yellow"
)
);
} }
return { return {
@ -133,23 +143,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 +172,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 +184,54 @@ 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 +245,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 +269,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);
}); });

View file

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

View file

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

View file

@ -0,0 +1,50 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import { formatLogLine } from "./logger-formatter";
import { LogLevel } from "sync-client";
test("formatLogLine - includes level and message", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Test message"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("INFO"));
assert.ok(result.includes("Test message"));
});
test("formatLogLine - ERROR level messages contain bold escape", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.ERROR,
message: "Error occurred"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[1m"));
});
test("formatLogLine - highlights file paths in quotes", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: 'Syncing "notes/test.md"'
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[35m"));
});
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Listed 42 files from v1.2.3"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[36m42\x1b[0m"));
assert.ok(!result.includes("\x1b[36m1\x1b[0m."));
});

View file

@ -1,36 +1,21 @@
import { LogLevel, type LogLine } from "sync-client"; 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");

View file

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

View file

@ -0,0 +1,60 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import { matchesGlob, toUnixPath } from "./path-utils";
test("matchesGlob - exact match", () => {
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
assert.equal(matchesGlob("other", ".DS_Store"), false);
});
test("matchesGlob - dir/** matches directory and contents", () => {
assert.equal(matchesGlob(".git", ".git/**"), true);
assert.equal(matchesGlob(".git/config", ".git/**"), true);
assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true);
assert.equal(matchesGlob(".gitignore", ".git/**"), false);
});
test("matchesGlob - * matches within a single segment", () => {
assert.equal(matchesGlob("foo.tmp", "*.tmp"), true);
assert.equal(matchesGlob("bar.tmp", "*.tmp"), true);
assert.equal(matchesGlob("foo.md", "*.tmp"), false);
assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false);
});
test("matchesGlob - **/*.ext matches at any depth", () => {
assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("foo.md", "**/*.tmp"), false);
});
test("matchesGlob - ? matches single character", () => {
assert.equal(matchesGlob("a.md", "?.md"), true);
assert.equal(matchesGlob("ab.md", "?.md"), false);
assert.equal(matchesGlob(".md", "?.md"), false);
});
test("matchesGlob - dots are literal", () => {
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false);
});
test("matchesGlob - node_modules/** matches directory tree", () => {
assert.equal(matchesGlob("node_modules", "node_modules/**"), true);
assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true);
assert.equal(
matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"),
true
);
assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false);
});
test("matchesGlob - **/ prefix matches zero or more segments", () => {
assert.equal(matchesGlob("test.log", "**/test.log"), true);
assert.equal(matchesGlob("dir/test.log", "**/test.log"), true);
assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true);
});
test("toUnixPath - forward slashes unchanged", () => {
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
});

View file

@ -0,0 +1,15 @@
import * as path from "path";
// Convert a native platform path to forward slashes (no-op on non-Windows)
export function toUnixPath(nativePath: string): string {
return nativePath.split(path.sep).join(path.posix.sep);
}
// Match a file path against a glob pattern
// Extends path.matchesGlob so that "dir/**" also matches the directory itself
export function matchesGlob(filePath: string, pattern: string): boolean {
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
return true;
}
return path.matchesGlob(filePath, pattern);
}

View file

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

View file

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

View file

@ -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! **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. 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 ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal. - Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page. - 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/`. - 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 ## API Documentation
See https://github.com/obsidianmd/obsidian-api See https://github.com/obsidianmd/obsidian-api

View file

@ -13,25 +13,25 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^24.8.1", "@types/node": "^25.0.2",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.2",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.4",
"obsidian": "1.10.2", "obsidian": "1.11.0",
"reconcile-text": "^0.8.0", "reconcile-text": "^0.11.0",
"resolve-url-loader": "^5.0.0", "resolve-url-loader": "^5.0.0",
"sass": "^1.91.0", "sass": "^1.96.0",
"sass-loader": "^16.0.6", "sass-loader": "^16.0.6",
"sync-client": "file:../sync-client", "sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14", "terser-webpack-plugin": "^5.3.16",
"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",
"url": "^0.11.4", "url": "^0.11.4",
"webpack": "^5.99.9", "webpack": "^5.103.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
} }

View file

@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
nativeLineEndings: Platform.isWin ? "\r\n" : "\n", nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD ...(IS_DEBUG_BUILD
? { ? {
fetch: debugging.slowFetchFactory(1), fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger()) webSocket: debugging.slowWebSocketFactory(1, new Logger())
} }
: {}) : {})
}); });
if (IS_DEBUG_BUILD) { if (IS_DEBUG_BUILD) {
debugging.logToConsole(client); debugging.logToConsole(client.logger);
} }
return client; 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) { if (file instanceof TFile) {
await client.syncLocallyCreatedFile(file.path); client.syncLocallyCreatedFile(file.path);
} }
}), }),
this.app.vault.on("modify", async (file: TAbstractFile) => { this.app.vault.on("modify", async (file: TAbstractFile) => {
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
await this.rateLimitedUpdate(file.path, client); await this.rateLimitedUpdate(file.path, client);
} }
}), }),
this.app.vault.on("delete", async (file: TAbstractFile) => { this.app.vault.on("delete", (file: TAbstractFile) => {
await client.syncLocallyDeletedFile(file.path); client.syncLocallyDeletedFile(file.path);
}), }),
this.app.vault.on( this.app.vault.on(
"rename", "rename",
async (file: TAbstractFile, oldPath: string) => { (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) { if (file instanceof TFile) {
await client.syncLocallyUpdatedFile({ client.syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath: file.path relativePath: file.path
}); });
@ -267,13 +267,11 @@ export default class VaultLinkPlugin extends Plugin {
if (!this.rateLimitedUpdatesPerFile.has(path)) { if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set( this.rateLimitedUpdatesPerFile.set(
path, path,
rateLimit( rateLimit(async () => {
async () => client.syncLocallyUpdatedFile({
client.syncLocallyUpdatedFile({ relativePath: path
relativePath: path });
}), }, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
); );
} }
await this.rateLimitedUpdatesPerFile.get(path)?.(); await this.rateLimitedUpdatesPerFile.get(path)?.();

View file

@ -14,7 +14,9 @@ export function renderCursorsInFileExplorer(
app: App app: App
): void { ): void {
const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
if (fileExplorers.length == 0) return; if (fileExplorers.length == 0) {
return;
}
const [fileExplorer] = fileExplorers; const [fileExplorer] = fileExplorers;
@ -34,7 +36,7 @@ export function renderCursorsInFileExplorer(
(parent) => { (parent) => {
cursors.forEach((cursor) => { cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => { cursor.documentsWithCursors.forEach((document) => {
if (document.relative_path.startsWith(key)) { if (document.relativePath.startsWith(key)) {
parent.appendChild( parent.appendChild(
createSpan({ createSpan({
text: cursor.userName, text: cursor.userName,

View file

@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
return clientCursors.flatMap((cursor) => return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({ cursor.cursors.map((span) => ({
name: client.userName, name: client.userName,
path: cursor.relative_path, path: cursor.relativePath,
deviceId: client.deviceId, deviceId: client.deviceId,
isOutdated: client.isOutdated, isOutdated: client.isOutdated,
span: { ...span } span: { ...span }
@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
] ]
) )
}, },
edited edited,
"Markdown"
); );
reconciled.cursors.forEach(({ id, position }) => { reconciled.cursors.forEach(({ id, position }) => {

View file

@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab {
new Notice("Checking connection to the server..."); new Notice("Checking connection to the server...");
new Notice( new Notice(
( (await this.syncClient.checkConnection())
await this.syncClient.checkConnection() .serverMessage
).serverMessage
); );
await this.statusDescription.updateConnectionState(); await this.statusDescription.updateConnectionState();
} else { } 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) new Setting(containerEl)
.setName("Maximum file size to be uploaded (MB)") .setName("Maximum file size to be uploaded (MB)")
.setDesc( .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( private setStatusDescriptionSubscription(

View file

@ -88,7 +88,7 @@ export class StatusDescription {
text: ` and has indexed approximately ` text: ` and has indexed approximately `
}); });
container.createSpan({ container.createSpan({
text: `${this.syncClient.documentCount}`, text: `${this.syncClient.syncedDocumentCount}`,
cls: "number" cls: "number"
}); });
container.createSpan({ container.createSpan({

View file

@ -6,12 +6,7 @@
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": [ "lib": ["DOM", "ES2024"]
"DOM",
"ES2024"
]
}, },
"exclude": [ "exclude": ["./dist"]
"./dist"
]
} }

View file

@ -46,7 +46,7 @@ module.exports = (env, argv) => ({
const source = path.resolve(__dirname, "dist"); const source = path.resolve(__dirname, "dist");
const destinations = [ const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/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" // "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
]; ];
destinations.forEach((destination) => { destinations.forEach((destination) => {