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 612 additions and 485 deletions

View file

@ -1,4 +1,4 @@
FROM node:22-slim AS builder
FROM node:25-slim AS builder
WORKDIR /build
@ -7,7 +7,7 @@ COPY . .
RUN npm ci
RUN npm run build
FROM node:22-alpine
FROM node:25-alpine
LABEL org.opencontainers.image.title="VaultLink Local CLI"
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"

View file

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

View file

@ -11,18 +11,16 @@
"build": "webpack --mode production",
"test": "tsx --test 'src/**/*.test.ts'"
},
"dependencies": {
"commander": "^14.0.2",
"watcher": "^2.3.1"
},
"devDependencies": {
"@types/node": "^24.8.1",
"commander": "^14.0.2",
"watcher": "^2.3.1",
"@types/node": "^25.0.2",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"ts-loader": "^9.5.4",
"tslib": "2.8.1",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1"
}
}

View file

@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => {
"mytoken",
"-v",
"default",
"--sync-concurrency",
"5",
"--max-file-size-mb",
"20"
]);
assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20);
});
@ -153,25 +150,6 @@ test("parseArgs - default log level is INFO", () => {
assert.equal(args.logLevel, LogLevel.INFO);
});
test("parseArgs - parse DEBUG log level", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"DEBUG"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - parse ERROR log level", () => {
const args = parseArgs([
"node",
@ -191,7 +169,31 @@ test("parseArgs - parse ERROR log level", () => {
assert.equal(args.logLevel, LogLevel.ERROR);
});
test("parseArgs - log level is case insensitive", () => {
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",
@ -200,31 +202,12 @@ test("parseArgs - log level is case insensitive", () => {
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"cli-token",
"-v",
"default",
"--log-level",
"debug"
"default"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - throws on invalid log level", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"INVALID"
]);
}, /Invalid log level/);
assert.equal(args.token, "cli-token");
} finally {
delete process.env.VAULTLINK_TOKEN;
}
});

View file

@ -1,19 +1,54 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import packageJson from "../package.json";
import { LogLevel } from "sync-client";
export interface CliArgs {
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
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://"];
const REQUIRED_OPTIONS = {
localPath: {
flags: "-l, --local-path <path>",
env: "VAULTLINK_LOCAL_PATH"
},
remoteUri: {
flags: "-r, --remote-uri <uri>",
env: "VAULTLINK_REMOTE_URI"
},
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
vaultName: {
flags: "-v, --vault-name <name>",
env: "VAULTLINK_VAULT_NAME"
}
} as const;
function requireOption<T>(
value: T | undefined,
name: keyof typeof REQUIRED_OPTIONS
): T {
if (value === undefined) {
const { flags, env } = REQUIRED_OPTIONS[name];
throw new Error(
`required option '${flags}' not specified (or set ${env})`
);
}
return value;
}
export function parseArgs(argv: string[]): CliArgs {
@ -25,41 +60,85 @@ 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(
REQUIRED_OPTIONS.localPath.flags,
"Local directory path to sync"
).env(REQUIRED_OPTIONS.localPath.env)
)
.option(
.addOption(
new Option(
REQUIRED_OPTIONS.remoteUri.flags,
"Remote server URI"
).env(REQUIRED_OPTIONS.remoteUri.env)
)
.addOption(
new Option(
REQUIRED_OPTIONS.token.flags,
"Authentication token"
).env(REQUIRED_OPTIONS.token.env)
)
.addOption(
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
REQUIRED_OPTIONS.vaultName.env
)
)
.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([...LINE_ENDING_MODES])
.env("VAULTLINK_LINE_ENDINGS")
)
.addHelpText(
"after",
@ -67,9 +146,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 +164,6 @@ Examples:
const remoteUri = opts.remoteUri as string | undefined;
const token = opts.token as string | undefined;
const vaultName = opts.vaultName as string | undefined;
const syncConcurrency = opts.syncConcurrency as number | undefined;
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
const ignorePattern = opts.ignorePattern as string[] | undefined;
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
@ -90,22 +172,23 @@ 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 requiredLocalPath = requireOption(localPath, "localPath");
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token");
const requiredVaultName = requireOption(vaultName, "vaultName");
// Validate remote URI protocol
if (
!VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix))
) {
throw new Error(
"required option '-l, --local-path <path>' not specified"
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
);
}
if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified");
}
// Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase();
@ -120,17 +203,27 @@ Examples:
}
const logLevel = logLevelUpper;
const isLineEndingMode = (value: string): value is LineEndingMode =>
(LINE_ENDING_MODES as readonly string[]).includes(value);
if (!isLineEndingMode(lineEndingsStr)) {
throw new Error(
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
);
}
const lineEndings = lineEndingsStr;
return {
localPath,
remoteUri,
token,
vaultName,
syncConcurrency,
localPath: requiredLocalPath,
remoteUri: requiredRemoteUri,
token: requiredToken,
vaultName: requiredVaultName,
maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel,
health,
enableTelemetry
enableTelemetry,
quiet,
lineEndings
};
}

View file

@ -5,24 +5,27 @@ import type { NetworkConnectionStatus } from "sync-client";
import {
SyncClient,
DEFAULT_SETTINGS,
Logger,
LogLevel,
LogLine,
type SyncSettings,
type StoredDatabase
} from "sync-client";
import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem";
import { parseArgs, type LineEndingMode } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } 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,41 @@ 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: LineEndingMode): 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 logHandler = createLogHandler(args.logLevel);
// Boot-time messages are emitted directly through logHandler before the
// SyncClient (and its Logger) exist; afterwards every log line flows
// through client.logger.
const emitBoot = (level: LogLevel, message: string): void => {
logHandler(new LogLine(level, message));
};
if (!fsSync.existsSync(absolutePath)) {
fsSync.mkdirSync(absolutePath, { recursive: true });
}
@ -48,38 +80,31 @@ 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")
);
emitBoot(LogLevel.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"
)
emitBoot(
LogLevel.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")
if (!args.quiet) {
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
if (args.lineEndings !== "auto") {
emitBoot(
LogLevel.INFO,
`Line endings: ${args.lineEndings.toUpperCase()}`
);
console.log(colorize("=".repeat(50), "dim"));
console.log(
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
);
console.log(
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
);
console.log(
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
);
console.log("");
}
}
const dataDir = path.join(absolutePath, ".vaultlink");
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true });
@ -88,8 +113,7 @@ async function main(): Promise<void> {
const ignorePatterns = [
...(args.ignorePatterns ?? []),
".vaultlink/**",
".git/**"
`${VAULTLINK_DIR}/**`
];
const settings: SyncSettings = {
@ -97,8 +121,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,11 +141,9 @@ 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"
)
emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
);
}
@ -133,23 +153,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 +182,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 +194,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 +255,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 +279,9 @@ async function main(): Promise<void> {
}
main().catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error(
colorize(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
});

View file

@ -1,15 +1,20 @@
import Watcher from "watcher";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
import { toUnixPath, matchesGlob } from "./path-utils";
export class FileWatcher {
private watcher: Watcher | undefined;
private isRunning = false;
private readonly ignorePatterns: string[];
public constructor(
private readonly basePath: string,
private readonly client: SyncClient
) {}
private readonly client: SyncClient,
ignorePatterns: string[] = []
) {
this.ignorePatterns = ignorePatterns;
}
public start(): void {
if (this.isRunning) {
@ -22,7 +27,8 @@ export class FileWatcher {
recursive: true,
renameDetection: true,
renameTimeout: 125,
ignoreInitial: true
ignoreInitial: true,
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
});
this.watcher.on("add", (filePath: string) => {
@ -56,66 +62,32 @@ export class FileWatcher {
this.client.logger.info("File watcher stopped");
}
private shouldIgnore(filePath: string): boolean {
const rel = toUnixPath(path.relative(this.basePath, filePath));
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
}
private handleCreate(relativePath: RelativePath): void {
this.client
.syncLocallyCreatedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyCreatedFile(relativePath);
}
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyUpdatedFile({ relativePath });
}
private handleDelete(relativePath: RelativePath): void {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyDeletedFile(relativePath);
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyUpdatedFile({
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));
}
}

View file

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

View file

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

View file

@ -1,36 +1,21 @@
import { LogLevel, type LogLine } from "sync-client";
// ANSI color codes
export const colors = {
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
// Foreground colors
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m"
} as const;
export function colorize(text: string, color: keyof typeof colors): string {
function colorize(text: string, color: keyof typeof colors): string {
return `${colors[color]}${text}${colors.reset}`;
}
/**
* Helper function to apply multiple color modifiers to text
*/
export function styleText(
text: string,
...modifiers: (keyof typeof colors)[]
): string {
const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`;
}
function formatTimestamp(date: Date): string {
const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0");

View file

@ -1,31 +1,32 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import type {
FileSystemOperations,
RelativePath,
TextWithCursors
} from "sync-client";
import { toUnixPath } from "./path-utils";
// VaultLink's per-vault metadata directory. Holds the persisted sync database
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
// ignore pattern keeps everything in here invisible to the file watcher.
export const VAULTLINK_DIR = ".vaultlink";
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {}
public constructor(private readonly basePath: string) { }
public async listFilesRecursively(
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 +40,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 +57,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 +72,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 +84,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 +94,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 +105,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 +119,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 +133,44 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
}
private async atomicWrite(
fullPath: string,
content: Uint8Array | string,
encoding?: BufferEncoding
): Promise<void> {
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
await fs.mkdir(tmpDir, { recursive: true });
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
try {
await fs.writeFile(tmpPath, content, encoding);
const fd = await fs.open(tmpPath, "r");
try {
await fd.datasync();
} finally {
await fd.close();
}
await fs.rename(tmpPath, fullPath);
await this.syncDirectory(path.dirname(fullPath));
} catch (error) {
await fs.unlink(tmpPath).catch(() => undefined);
throw error;
}
}
// Make the rename durable by fsync'ing the destination's parent directory.
// Skipped on Windows: fsync on a directory handle isn't supported there
private async syncDirectory(dir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
const fd = await fs.open(dir, "r");
try {
await fd.sync();
} finally {
await fd.close();
}
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
@ -179,28 +194,8 @@ export class NodeFileSystemOperations implements FileSystemOperations {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(this.toUnixPath(entryRelativePath));
files.push(toUnixPath(entryRelativePath));
}
}
}
/**
* Convert a forward-slash path to native platform path separators
*/
private toNativePath(relativePath: string): string {
if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\");
}
return relativePath;
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
}

View file

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

View file

@ -0,0 +1,21 @@
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.
//
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
// the directory `dir` itself, not only its descendants. The watcher feeds us
// a directory's relative path (e.g. ".git") at the same time it's about to
// recurse into it, and the natural way for users to write the ignore pattern
// is `.git/**` — under stdlib semantics that pattern would let the directory
// through and only block its children, defeating the prune.
export function matchesGlob(filePath: string, pattern: string): boolean {
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
return true;
}
return path.matchesGlob(filePath, pattern);
}

View file

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

View file

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

View file

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

View file

@ -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)?.();

View file

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

View file

@ -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 }) => {

View file

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

View file

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

View file

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

View file

@ -46,8 +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",
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
];
destinations.forEach((destination) => {
fs.copy(source, destination)