Add local CLI #144

Merged
schmelczer merged 16 commits from asch/local-cli into main 2025-10-21 22:45:47 +01:00
18 changed files with 1615 additions and 48 deletions
Showing only changes of commit b160dadf44 - Show all commits

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";
copilot-pull-request-reviewer[bot] commented 2025-10-21 22:42:14 +01:00 (Migrated from github.com)

This commented-out code creates a never-resolving promise that would block the program indefinitely. If this was intended to keep the process alive, it should either be removed or uncommented with a clear explanation. Currently, without this or any other blocking mechanism, the program may exit prematurely after starting the file watcher.

		// Block the main function to keep the process alive until interrupted.
		await new Promise<void>(() => {
			// This promise never resolves; process stays alive until SIGINT/SIGTERM.
		});
This commented-out code creates a never-resolving promise that would block the program indefinitely. If this was intended to keep the process alive, it should either be removed or uncommented with a clear explanation. Currently, without this or any other blocking mechanism, the program may exit prematurely after starting the file watcher. ```suggestion // Block the main function to keep the process alive until interrupted. await new Promise<void>(() => { // This promise never resolves; process stays alive until SIGINT/SIGTERM. }); ```
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"
)
);
copilot-pull-request-reviewer[bot] commented 2025-10-21 22:42:14 +01:00 (Migrated from github.com)

The type assertion bypasses type safety without validation. The parsed JSON should be validated to ensure it matches the expected StoredDatabase structure before casting, or handle potential type mismatches gracefully in case the data file is corrupted.

The type assertion bypasses type safety without validation. The parsed JSON should be validated to ensure it matches the expected StoredDatabase structure before casting, or handle potential type mismatches gracefully in case the data file is corrupted.
}
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
copilot-pull-request-reviewer[bot] commented 2025-10-21 22:42:14 +01:00 (Migrated from github.com)

Recursive call to the script without arguments will lose the FIX_MODE=true state, causing infinite recursion or unexpected behavior. The script should pass the original arguments or at least preserve the FIX_MODE flag: $0 \"$@\" or FIX_MODE=true $0.

    $0 "$@"
Recursive call to the script without arguments will lose the FIX_MODE=true state, causing infinite recursion or unexpected behavior. The script should pass the original arguments or at least preserve the FIX_MODE flag: `$0 \"$@\"` or `FIX_MODE=true $0`. ```suggestion $0 "$@" ```
echo "Success"