Add local CLI #144
18 changed files with 1615 additions and 48 deletions
102
.github/workflows/publish-cli.yml
vendored
Normal file
102
.github/workflows/publish-cli.yml
vendored
Normal 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
|
||||
9
frontend/local-client-cli/.dockerignore
Normal file
9
frontend/local-client-cli/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.test.ts
|
||||
coverage
|
||||
.vaultlink
|
||||
58
frontend/local-client-cli/Dockerfile
Normal file
58
frontend/local-client-cli/Dockerfile
Normal 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"]
|
||||
288
frontend/local-client-cli/README.md
Normal file
288
frontend/local-client-cli/README.md
Normal 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
|
||||
|
||||
28
frontend/local-client-cli/package.json
Normal file
28
frontend/local-client-cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
frontend/local-client-cli/src/args.test.ts
Normal file
136
frontend/local-client-cli/src/args.test.ts
Normal 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/);
|
||||
});
|
||||
103
frontend/local-client-cli/src/args.ts
Normal file
103
frontend/local-client-cli/src/args.ts
Normal 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
|
||||
};
|
||||
}
|
||||
206
frontend/local-client-cli/src/cli.ts
Normal file
206
frontend/local-client-cli/src/cli.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
|
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);
|
||||
});
|
||||
102
frontend/local-client-cli/src/file-watcher.ts
Normal file
102
frontend/local-client-cli/src/file-watcher.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
frontend/local-client-cli/src/logger-formatter.ts
Normal file
86
frontend/local-client-cli/src/logger-formatter.ts
Normal 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}`;
|
||||
}
|
||||
162
frontend/local-client-cli/src/node-filesystem.test.ts
Normal file
162
frontend/local-client-cli/src/node-filesystem.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
203
frontend/local-client-cli/src/node-filesystem.ts
Normal file
203
frontend/local-client-cli/src/node-filesystem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
frontend/local-client-cli/tsconfig.json
Normal file
20
frontend/local-client-cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
30
frontend/local-client-cli/webpack.config.js
Normal file
30
frontend/local-client-cli/webpack.config.js
Normal 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 })
|
||||
]
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
35
frontend/package-lock.json
generated
35
frontend/package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
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:
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue
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.