Implement

This commit is contained in:
Andras Schmelczer 2025-10-20 20:22:29 +01:00
parent d1c4b319a5
commit b160dadf44
18 changed files with 1615 additions and 48 deletions

102
.github/workflows/publish-cli.yml vendored Normal file
View file

@ -0,0 +1,102 @@
name: Publish CLI
on:
push:
tags: ["*"]
pull_request:
branches: ["main"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-cli
jobs:
publish-docker:
runs-on: self-hosted
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install cosign
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: "v2.2.4"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: frontend
file: frontend/local-client-cli/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Sign the published Docker image
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
publish-npm:
runs-on: self-hosted
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4.2.0
with:
node-version: "22.x"
check-latest: true
registry-url: 'https://npm.pkg.github.com'
scope: '@schmelczer'
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Build CLI
run: |
cd frontend/local-client-cli
npm run build
- name: Publish to GitHub Packages
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd frontend/local-client-cli
# Update package.json to use scoped name for GitHub Packages
npm version --no-git-tag-version ${GITHUB_REF#refs/tags/}
npm publish

View file

@ -0,0 +1,9 @@
node_modules
dist
*.log
.git
.gitignore
README.md
*.test.ts
coverage
.vaultlink

View file

@ -0,0 +1,58 @@
FROM node:22-alpine AS builder
WORKDIR /build
# Copy workspace root package files
COPY package*.json ./
# Copy package.json files for each workspace
COPY sync-client/package*.json ./sync-client/
COPY local-client-cli/package*.json ./local-client-cli/
# Install dependencies
RUN npm ci --workspaces
# Copy source code
COPY sync-client/ ./sync-client/
COPY local-client-cli/ ./local-client-cli/
WORKDIR /build/sync-client
RUN npm run build
WORKDIR /build/local-client-cli
RUN npm run build
# Stage 2: Runtime image
FROM node:22-alpine
# Add labels for metadata
LABEL org.opencontainers.image.title="VaultLink Local CLI"
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
LABEL org.opencontainers.image.source="https://github.com/schmelczer/vault-link"
LABEL org.opencontainers.image.licenses="MIT"
# Create non-root user
RUN addgroup -g 1001 vaultlink && \
adduser -D -u 1001 -G vaultlink vaultlink
# Create vault directory
RUN mkdir -p /vault && \
chown -R vaultlink:vaultlink /vault
# Copy only the built CLI
COPY --from=builder --chown=vaultlink:vaultlink /build/local-client-cli/dist/cli.js /app/cli.js
# Switch to non-root user
USER vaultlink
# Set working directory to vault
WORKDIR /vault
# Volume for vault data
VOLUME ["/vault"]
# Entry point
ENTRYPOINT ["node", "/app/cli.js"]
# Default: show help
CMD ["--help"]

View file

@ -0,0 +1,288 @@
# VaultLink Local CLI
A standalone command-line interface for syncing VaultLink vaults to your local filesystem. This CLI wraps the VaultLink sync client and provides file watching capabilities for real-time synchronization.
## Features
- Real-time bidirectional sync between local filesystem and VaultLink server
- File watching with automatic change detection
- Cross-platform support (Linux, macOS, Windows)
- Configuration via command-line arguments or JSON file
- Comprehensive error handling and logging
- Graceful shutdown on SIGINT/SIGTERM
## Installation
### Using Docker (Recommended)
The easiest way to run VaultLink CLI is using Docker:
```bash
docker pull ghcr.io/schmelczer/vault-link-cli:latest
# Run with all options via command line
docker run -v /path/to/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r https://sync.example.com \
-t your-auth-token \
-v default
# Or use a config file
docker run -v /path/to/vault:/vault \
-v /path/to/config.json:/config.json \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-c /config.json
```
### Using Docker Compose
```yaml
version: '3.8'
services:
vaultlink-cli:
image: ghcr.io/schmelczer/vault-link-cli:latest
volumes:
- ./vault:/vault
- ./config.json:/config.json
command: -l /vault -c /config.json
restart: unless-stopped
```
### From npm Package
```bash
npm install -g @schmelczer/local-client-cli
vaultlink --help
```
### From Source
```bash
cd frontend/local-client-cli
npm install
npm run build
node dist/cli.js --help
```
## Usage
### Basic Usage
```bash
vaultlink \
--local-path ./my-vault \
--remote-uri https://sync.example.com \
--token your-auth-token \
--vault-name default
```
### Using a Configuration File
Create a `config.json` file:
```json
{
"remoteUri": "https://sync.example.com",
"token": "your-auth-token",
"vaultName": "default",
"syncConcurrency": 1,
"maxFileSizeMB": 10,
"ignorePatterns": [".git/**", "*.tmp"],
"webSocketRetryIntervalMs": 3500
}
```
Then run:
```bash
vaultlink --local-path ./my-vault --config config.json
```
### Command-Line Options
#### Required Arguments
- `-l, --local-path <PATH>` - Local directory path to sync
- `-r, --remote-uri <URI>` - Remote server URI (unless using --config)
- `-t, --token <TOKEN>` - Authentication token (unless using --config)
- `-v, --vault-name <NAME>` - Vault name (unless using --config)
#### Optional Arguments
- `-c, --config <FILE>` - Load configuration from JSON file
- `--sync-concurrency <NUM>` - Number of concurrent sync operations (default: 1)
- `--max-file-size-mb <NUM>` - Maximum file size in MB (default: 10)
- `--ignore-pattern <PATTERN>` - Pattern to ignore (can be used multiple times)
- `--websocket-retry-interval-ms <NUM>` - WebSocket retry interval in ms (default: 3500)
- `-h, --help` - Print help message
- `-V, --version` - Print version
### Ignore Patterns
Ignore patterns support glob syntax. The CLI automatically ignores:
- `.vaultlink/**` - Internal sync data directory
- `.git/**` - Git repository files
You can add additional patterns:
```bash
vaultlink \
--local-path ./my-vault \
--remote-uri https://sync.example.com \
--token mytoken \
--vault-name default \
--ignore-pattern "*.tmp" \
--ignore-pattern ".DS_Store" \
--ignore-pattern "node_modules/**"
```
## Docker Deployment
### Self-Hosting with Docker
The CLI is designed to run as a long-lived container:
```bash
# Create a vault directory
mkdir -p ./vault
# Create config.json
cat > config.json <<EOF
{
"remoteUri": "https://your-server.com",
"token": "your-token",
"vaultName": "default"
}
EOF
# Run the container
docker run -d \
--name vaultlink-sync \
--restart unless-stopped \
-v $(pwd)/vault:/vault \
-v $(pwd)/config.json:/config.json \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -c /config.json
```
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vaultlink-cli
spec:
replicas: 1
selector:
matchLabels:
app: vaultlink-cli
template:
metadata:
labels:
app: vaultlink-cli
spec:
containers:
- name: vaultlink-cli
image: ghcr.io/schmelczer/vault-link-cli:latest
args:
- "-l"
- "/vault"
- "-c"
- "/config/config.json"
volumeMounts:
- name: vault
mountPath: /vault
- name: config
mountPath: /config
volumes:
- name: vault
persistentVolumeClaim:
claimName: vaultlink-pvc
- name: config
configMap:
name: vaultlink-config
```
## How It Works
1. **Initialization**: The CLI creates a `.vaultlink` directory in your local path to store sync metadata and state
2. **Initial Sync**: On first run, all local files are synced to the server
3. **File Watching**: The CLI watches for file system changes using Node's `fs.watch` API
4. **Real-time Sync**: Any local changes are automatically synced to the server, and server changes are applied locally
5. **Graceful Shutdown**: On Ctrl+C or termination signals, the CLI waits for pending operations to complete
## Development
### Building
```bash
npm run build
# or from the parent folder, run
docker build -f local-client-cli/Dockerfile .
```
### Development Mode with Watch
```bash
npm run dev
```
### Running Tests
```bash
npm test
```
Tests cover:
- Filesystem operations (read, write, delete, rename, etc.)
- CLI argument parsing and validation
- Cross-platform path handling
- Error handling
## Architecture
The CLI consists of several key components:
- **`cli.ts`**: Main entry point, orchestrates initialization and lifecycle
- **`node-filesystem.ts`**: Node.js filesystem adapter implementing the `FileSystemOperations` interface with cross-platform path normalization
- **`file-watcher.ts`**: Watches filesystem changes and triggers sync operations
- **`args.ts`**: Command-line argument parser using `commander` library
- **`config-loader.ts`**: JSON configuration file loader and merger
### Dependencies
- **commander**: Industry-standard CLI argument parsing with built-in help generation and validation
- **sync-client**: Core VaultLink synchronization library
### Path Handling
The filesystem adapter ensures cross-platform compatibility by:
- **API Contract**: Always accepts forward slashes (`/`) as input
- **API Contract**: Always returns forward slashes (`/`) as output
- **Implementation**: Converts between forward slashes and platform-native separators internally
- **Windows Support**: Automatically converts `/` to `\` on Windows for filesystem operations
## Error Handling
The CLI provides robust error handling:
- Invalid arguments result in clear error messages and exit code 1
- Connection failures are reported before starting sync
- File operation errors are logged with context
- Graceful shutdown ensures no data loss on termination
## Cross-Platform Support
The CLI is built with cross-platform compatibility:
- Uses Node's `path` module for platform-agnostic path handling
- Automatically detects platform line endings (CRLF on Windows, LF on Unix)
- File watching works on all platforms through Node's native `fs.watch`
- Compiled with Webpack for Node target, ensuring broad compatibility
- Path normalization ensures consistent behavior across Windows, macOS, and Linux

View file

@ -0,0 +1,28 @@
{
"name": "local-client-cli",
"version": "0.8.2",
"description": "Standalone CLI for VaultLink sync client",
"private": false,
"bin": {
"vaultlink": "./dist/cli.js"
},
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts"
},
"dependencies": {
"commander": "^12.1.0"
},
"devDependencies": {
"@types/node": "^24.8.1",
"bufferutil": "^4.0.9",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.5",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -0,0 +1,136 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import { parseArgs } from "./args";
test("parseArgs - parse basic arguments", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default"
]);
assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default");
});
test("parseArgs - parse long form arguments", () => {
const args = parseArgs([
"node",
"cli.js",
"--local-path",
"/path/to/vault",
"--remote-uri",
"https://sync.example.com",
"--token",
"mytoken",
"--vault-name",
"default"
]);
assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default");
});
test("parseArgs - parse with optional arguments", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--sync-concurrency",
"5",
"--max-file-size-mb",
"20"
]);
assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20);
});
test("parseArgs - parse with multiple ignore patterns", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--ignore-pattern",
".git/**",
"*.tmp"
]);
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
});
test("parseArgs - throws on missing required arguments", () => {
assert.throws(() => {
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
}, /required option/);
});
test("parseArgs - throws on missing remote uri", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-t",
"mytoken",
"-v",
"default"
]);
}, /--remote-uri/);
});
test("parseArgs - throws on missing token", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-v",
"default"
]);
}, /--token/);
});
test("parseArgs - throws on missing vault name", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken"
]);
}, /--vault-name/);
});

View file

@ -0,0 +1,103 @@
import { Command } from "commander";
import packageJson from "../package.json";
export interface CliArgs {
remoteUri: string;
token: string;
vaultName: string;
localPath: string;
syncConcurrency?: number;
maxFileSizeMB?: number;
ignorePatterns?: string[];
webSocketRetryIntervalMs?: number;
}
export function parseArgs(argv: string[]): CliArgs {
const program = new Command();
program
.name("vaultlink")
.description(
"VaultLink Local CLI - Sync your vault to the local filesystem"
)
.version(packageJson.version)
.exitOverride((err) => {
// Let help and version exit normally
if (
err.code === "commander.helpDisplayed" ||
err.code === "commander.version"
) {
process.exit(0);
}
throw err;
})
.requiredOption(
"-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
)
.option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB",
parseInt
)
.option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
)
.option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt
)
.addHelpText(
"after",
`
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"
`
);
program.parse(argv);
const options = program.opts<{
localPath: string;
remoteUri?: string;
token?: string;
vaultName?: string;
syncConcurrency?: number;
maxFileSizeMb?: number;
ignorePattern?: string[];
websocketRetryIntervalMs?: number;
}>();
if (options.remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (options.token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (options.vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified");
}
return {
localPath: options.localPath,
remoteUri: options.remoteUri ?? "",
token: options.token ?? "",
vaultName: options.vaultName ?? "",
syncConcurrency: options.syncConcurrency,
maxFileSizeMB: options.maxFileSizeMb,
ignorePatterns: options.ignorePattern,
webSocketRetryIntervalMs: options.websocketRetryIntervalMs
};
}

View file

@ -0,0 +1,206 @@
import * as path from "path";
import * as fs from "fs/promises";
import {
SyncClient,
DEFAULT_SETTINGS,
type SyncSettings,
type StoredDatabase
} from "sync-client";
import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem";
import { FileWatcher } from "./file-watcher";
import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json";
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
try {
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
console.error(
colorize(`Error: ${absolutePath} is not a directory`, "red")
);
process.exit(1);
}
} catch (error) {
console.error(
colorize(
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
}
// Print header with colors
console.log(
styleText("VaultLink Local CLI", "bold", "cyan") +
colorize(` v${packageJson.version}`, "dim")
);
console.log(colorize("=".repeat(50), "dim"));
console.log(
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
);
console.log(
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
);
console.log(
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
);
console.log("");
const dataDir = path.join(absolutePath, ".vaultlink");
const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true });
const fileSystem = new NodeFileSystemOperations(absolutePath);
const ignorePatterns = [
...(args.ignorePatterns ?? []),
".vaultlink/**",
".git/**"
];
const settings: SyncSettings = {
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true
};
const client = await SyncClient.create({
fs: fileSystem,
persistence: {
load: async () => {
let database: Partial<StoredDatabase> | undefined = undefined;
try {
const content = await fs.readFile(dataFile, "utf-8");
// 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"
)
);
}
return {
settings,
database
};
},
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"
});
// Add colored log formatter
client.logger.addOnMessageListener((logLine) => {
console.log(formatLogLine(logLine));
});
client.logger.info("Starting sync client");
const fileWatcher = new FileWatcher(absolutePath, client);
client.addWebSocketStatusChangeListener(() => {
const currentSettings = client.getSettings();
if (currentSettings.isSyncEnabled) {
client.logger.info("WebSocket status changed");
}
});
client.addRemainingSyncOperationsListener((remaining) => {
if (remaining === 0) {
client.logger.info("All sync operations completed");
} else {
client.logger.info(`${remaining} sync operations remaining`);
}
});
const gracefulShutdown = async (signal: string): Promise<void> => {
console.log(
colorize(
`\n${signal} received. Shutting down gracefully...`,
"yellow"
)
);
fileWatcher.stop();
await client.waitAndStop();
console.log(colorize("Shutdown complete", "green"));
process.exit(0);
};
process.on("SIGINT", () => {
void gracefulShutdown("SIGINT");
});
process.on("SIGTERM", () => {
void gracefulShutdown("SIGTERM");
});
try {
const connectionStatus = await client.checkConnection();
if (!connectionStatus.isSuccessful) {
console.error(
colorize(
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
"red"
)
);
process.exit(1);
}
console.log(`${colorize("✓", "green")} Server connection successful`);
console.log("");
await client.start();
fileWatcher.start();
console.log(styleText("✓ Sync started", "bold", "green"));
console.log(colorize("Press Ctrl+C to stop", "dim"));
console.log(colorize("─".repeat(50), "dim"));
console.log("");
await new Promise<void>(() => {
// Keep process alive until signal received
});
} catch (error) {
console.error(
colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
fileWatcher.stop();
await client.waitAndStop();
process.exit(1);
}
}
main().catch((error: unknown) => {
console.error(
colorize(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
});

View file

@ -0,0 +1,102 @@
import * as fs from "fs";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
export class FileWatcher {
private watcher: fs.FSWatcher | undefined;
private isRunning = false;
public constructor(
private readonly basePath: string,
private readonly client: SyncClient
) {}
public start(): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
this.watcher = fs.watch(
this.basePath,
{ recursive: true },
(eventType, filename) => {
if (filename === null || filename.length === 0) {
return;
}
// Convert to forward slashes for consistency
const relativePath = this.toUnixPath(filename);
if (eventType === "rename") {
this.handleRenameOrDelete(relativePath);
} else {
// Must be "change" event
this.handleChange(relativePath);
}
}
);
this.client.logger.info("File watcher started");
}
public stop(): void {
if (this.watcher !== undefined) {
this.watcher.close();
this.watcher = undefined;
}
this.isRunning = false;
this.client.logger.info("File watcher stopped");
}
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
);
});
}
private handleRenameOrDelete(relativePath: RelativePath): void {
const fullPath = path.join(this.basePath, relativePath);
fs.access(fullPath, fs.constants.F_OK, (accessError) => {
if (accessError) {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((deleteErr: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}`
);
});
} else {
fs.stat(fullPath, (statErr, stats) => {
if (statErr !== null || !stats.isFile()) {
return;
}
this.client
.syncLocallyCreatedFile(relativePath)
.catch((createErr: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}`
);
});
});
}
});
}
/**
* 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,86 @@
import { LogLevel, type LogLine } from "sync-client";
// ANSI color codes
export 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 {
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");
return colorize(`${time}.${ms}`, "gray");
}
function formatLevel(level: LogLevel): string {
const levelStr = level.padEnd(7);
switch (level) {
case LogLevel.DEBUG:
return colorize(levelStr, "cyan");
case LogLevel.INFO:
return colorize(levelStr, "green");
case LogLevel.WARNING:
return colorize(levelStr, "yellow");
case LogLevel.ERROR:
return colorize(levelStr, "red");
}
}
function formatMessage(message: string, level: LogLevel): string {
// Highlight important parts of the message
let formatted = message;
// Highlight file paths
formatted = formatted.replace(
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
);
// Highlight numbers
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
// Highlight patterns like /regex/
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
colorize(pattern, "yellow")
);
// Make error messages bold
if (level === LogLevel.ERROR) {
formatted = colorize(formatted, "bold");
}
return formatted;
}
export function formatLogLine(logLine: LogLine): string {
const timestamp = formatTimestamp(logLine.timestamp);
const level = formatLevel(logLine.level);
const message = formatMessage(logLine.message, logLine.level);
return `${timestamp} ${level} ${message}`;
}

View file

@ -0,0 +1,162 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
import { NodeFileSystemOperations } from "./node-filesystem";
test("NodeFileSystemOperations - read and write files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content);
const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content);
const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - exists with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
assert.equal(await fsOps.exists("test.txt"), false);
await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - delete with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.delete("test.txt");
assert.equal(await fsOps.exists("test.txt"), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - rename with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "new.txt");
assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("new.txt"), true);
const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - getFileSize", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content);
const size = await fsOps.getFileSize("test.txt");
assert.equal(size, content.length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - atomicUpdateText", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World",
cursors: []
}));
assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
// API should always accept forward slashes
const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test");
await fsOps.write(testPath, content);
assert.equal(await fsOps.exists(testPath), true);
const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test");
await fsOps.delete(testPath);
assert.equal(await fsOps.exists(testPath), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,203 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import type { FileSystemOperations, RelativePath } from "sync-client";
import type { TextWithCursors } from "reconcile-text";
export class NodeFileSystemOperations implements FileSystemOperations {
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
);
return files;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
return await fs.readFile(fullPath);
} catch (error) {
throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async write(
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const dir = path.dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async atomicUpdateText(
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const stats = await fs.stat(fullPath);
return stats.size;
} catch (error) {
throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.unlink(fullPath);
} catch (error) {
throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async rename(
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 newDir = path.dirname(newFullPath);
try {
await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath);
} catch (error) {
throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = [];
try {
entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) {
throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
for (const entry of entries) {
const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(this.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,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

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

View file

@ -50,51 +50,46 @@ export default class VaultLinkPlugin extends Plugin {
".trash/**"
);
plausibleInit({
domain: "vault-link",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true
});
Sentry.init({
dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1",
skipBrowserExtensionCheck: false
});
const onError = (event: ErrorEvent): void => {
Sentry.captureException(event.error, {
extra: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
});
};
window.addEventListener("error", onError);
this.disposables.push(() => {
window.removeEventListener("error", onError);
});
const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
Sentry.captureException(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandledRejection);
this.disposables.push(() => {
window.removeEventListener(
"unhandledrejection",
onUnhandledRejection
);
});
const isDebugBuild = process.env.NODE_ENV === "development";
if (!isDebugBuild) {
plausibleInit({
domain: "vault-link",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true
});
Sentry.init({
dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1",
skipBrowserExtensionCheck: false
});
const onError = (event: ErrorEvent): void => {
Sentry.captureException(event.error, {
extra: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
});
};
window.addEventListener("error", onError);
this.disposables.push(() => {
window.removeEventListener("error", onError);
});
const onUnhandledRejection = (
event: PromiseRejectionEvent
): void => {
Sentry.captureException(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandledRejection);
this.disposables.push(() => {
window.removeEventListener(
"unhandledrejection",
onUnhandledRejection
);
});
}
const debugOptions = isDebugBuild
? {
fetch: debugging.slowFetchFactory(1),

View file

@ -8,7 +8,8 @@
"workspaces": [
"sync-client",
"obsidian-plugin",
"test-client"
"test-client",
"local-client-cli"
],
"devDependencies": {
"concurrently": "^9.2.1",
@ -19,6 +20,34 @@
"typescript-eslint": "8.41.0"
}
},
"local-client-cli": {
"version": "0.8.2",
"dependencies": {
"commander": "^12.1.0"
},
"bin": {
"vaultlink": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.8.1",
"bufferutil": "^4.0.9",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.5",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
},
"local-client-cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"engines": {
"node": ">=18"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"dev": true,
@ -2793,6 +2822,10 @@
"node": ">=8.9.0"
}
},
"node_modules/local-client-cli": {
"resolved": "local-client-cli",
"link": true
},
"node_modules/locate-path": {
"version": "6.0.0",
"dev": true,

View file

@ -4,7 +4,8 @@
"workspaces": [
"sync-client",
"obsidian-plugin",
"test-client"
"test-client",
"local-client-cli"
],
"prettier": {
"trailingComma": "none",
@ -16,7 +17,7 @@
"build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client && prettier --write \"**/*.ts\"",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws"
},
"devDependencies": {

View file

@ -36,6 +36,11 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
exit 1
fi
echo "Success"
cd ..
if [[ "$FIX_MODE" == true ]]; then
$0
fi
echo "Success"