Add local CLI (#144)
This commit is contained in:
parent
a31c2d87b5
commit
90752e687a
24 changed files with 1616 additions and 99 deletions
64
.github/workflows/publish-cli-docker.yml
vendored
Normal file
64
.github/workflows/publish-cli-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
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
|
||||||
|
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}
|
||||||
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
|
||||||
25
frontend/local-client-cli/Dockerfile
Normal file
25
frontend/local-client-cli/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
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"
|
||||||
|
LABEL org.opencontainers.image.authors="andras@schmelczer.dev"
|
||||||
|
|
||||||
|
COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js
|
||||||
|
|
||||||
|
WORKDIR /vault
|
||||||
|
|
||||||
|
VOLUME ["/vault"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/cli.js"]
|
||||||
|
CMD ["--help"]
|
||||||
206
frontend/local-client-cli/README.md
Normal file
206
frontend/local-client-cli/README.md
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
# VaultLink Local CLI
|
||||||
|
|
||||||
|
Standalone CLI for syncing VaultLink vaults to local filesystem with real-time bidirectional sync and file watching.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/schmelczer/vault-link-cli:latest
|
||||||
|
|
||||||
|
docker run -v /path/to/vault:/vault \
|
||||||
|
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||||
|
-l /vault \
|
||||||
|
-r wss://sync.example.com \
|
||||||
|
-t your-auth-token \
|
||||||
|
-v default
|
||||||
|
```
|
||||||
|
|
||||||
|
### npm
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vaultlink \
|
||||||
|
--local-path ./vault \
|
||||||
|
--remote-uri wss://sync.example.com \
|
||||||
|
--token your-auth-token \
|
||||||
|
--vault-name default
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `-l, --local-path <path>` | Local directory to sync |
|
||||||
|
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||||
|
| `-t, --token <token>` | Authentication token |
|
||||||
|
| `-v, --vault-name <name>` | Vault name on server |
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
||||||
|
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||||
|
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||||
|
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||||
|
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||||
|
| `-h, --help` | - | Show help |
|
||||||
|
| `-V, --version` | - | Show version |
|
||||||
|
|
||||||
|
### Auto-Ignored Patterns
|
||||||
|
|
||||||
|
- `.vaultlink/**` - Internal sync metadata
|
||||||
|
- `.git/**` - Git repository files
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Basic usage:
|
||||||
|
```bash
|
||||||
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
||||||
|
```
|
||||||
|
|
||||||
|
With ignore patterns:
|
||||||
|
```bash
|
||||||
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
|
--ignore-pattern "*.tmp" \
|
||||||
|
--ignore-pattern ".DS_Store" \
|
||||||
|
--ignore-pattern "node_modules/**"
|
||||||
|
```
|
||||||
|
|
||||||
|
With debug logging:
|
||||||
|
```bash
|
||||||
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
|
--log-level DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Docker Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name vaultlink-sync \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v $(pwd)/vault:/vault \
|
||||||
|
ghcr.io/schmelczer/vault-link-cli:latest \
|
||||||
|
-l /vault \
|
||||||
|
-r wss://your-server.com \
|
||||||
|
-t your-token \
|
||||||
|
-v default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
vaultlink-cli:
|
||||||
|
image: ghcr.io/schmelczer/vault-link-cli:latest
|
||||||
|
volumes:
|
||||||
|
- ./vault:/vault
|
||||||
|
command:
|
||||||
|
- "-l"
|
||||||
|
- "/vault"
|
||||||
|
- "-r"
|
||||||
|
- "wss://sync.example.com"
|
||||||
|
- "-t"
|
||||||
|
- "your-token"
|
||||||
|
- "-v"
|
||||||
|
- "default"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
The Docker container includes a built-in healthcheck that monitors the WebSocket connection to the server.
|
||||||
|
|
||||||
|
### Healthcheck Configuration
|
||||||
|
|
||||||
|
- **Interval**: 30 seconds
|
||||||
|
- **Timeout**: 10 seconds
|
||||||
|
- **Start period**: 30 seconds (grace period for initial connection)
|
||||||
|
- **Retries**: 3 failed checks before marking unhealthy
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
The CLI writes connection status to `/tmp/vaultlink-health.json` every 10 seconds and whenever the WebSocket connection status changes. The healthcheck script verifies:
|
||||||
|
|
||||||
|
1. The health file exists
|
||||||
|
2. The status is recent (updated within last 30 seconds)
|
||||||
|
3. The WebSocket connection is active
|
||||||
|
|
||||||
|
### Checking Container Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View health status
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# View detailed health check logs
|
||||||
|
docker inspect --format='{{json .State.Health}}' vaultlink-sync | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Healthcheck
|
||||||
|
|
||||||
|
To override the default healthcheck in docker-compose.yml:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
vaultlink-cli:
|
||||||
|
image: ghcr.io/schmelczer/vault-link-cli:latest
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "/app/healthcheck.js"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Build:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# or from the parent folder, run
|
||||||
|
docker build -f local-client-cli/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
Test:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker build:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Creates `.vaultlink` directory for sync metadata
|
||||||
|
2. Performs initial sync of local files to server
|
||||||
|
3. Watches filesystem for changes using Node's `fs.watch`
|
||||||
|
4. Syncs changes bidirectionally in real-time
|
||||||
|
5. Handles graceful shutdown on SIGINT/SIGTERM
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
27
frontend/local-client-cli/package.json
Normal file
27
frontend/local-client-cli/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
230
frontend/local-client-cli/src/args.test.ts
Normal file
230
frontend/local-client-cli/src/args.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import * as assert from "node:assert/strict";
|
||||||
|
import { parseArgs } from "./args";
|
||||||
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - default log level is INFO", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.logLevel, LogLevel.INFO);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse DEBUG log level", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--log-level",
|
||||||
|
"DEBUG"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--log-level",
|
||||||
|
"ERROR"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - log level is case insensitive", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--log-level",
|
||||||
|
"debug"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - throws on invalid log level", () => {
|
||||||
|
assert.throws(() => {
|
||||||
|
parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--log-level",
|
||||||
|
"INVALID"
|
||||||
|
]);
|
||||||
|
}, /Invalid log level/);
|
||||||
|
});
|
||||||
122
frontend/local-client-cli/src/args.ts
Normal file
122
frontend/local-client-cli/src/args.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { Command } from "commander";
|
||||||
|
import packageJson from "../package.json";
|
||||||
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
|
export interface CliArgs {
|
||||||
|
remoteUri: string;
|
||||||
|
token: string;
|
||||||
|
vaultName: string;
|
||||||
|
localPath: string;
|
||||||
|
syncConcurrency?: number;
|
||||||
|
maxFileSizeMB?: number;
|
||||||
|
ignorePatterns?: string[];
|
||||||
|
webSocketRetryIntervalMs?: number;
|
||||||
|
logLevel: LogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||||
|
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||||
|
.option("-t, --token <token>", "Authentication token")
|
||||||
|
.option("-v, --vault-name <name>", "Vault name")
|
||||||
|
.option(
|
||||||
|
"--sync-concurrency <number>",
|
||||||
|
"[OPTIONAL] Number of concurrent sync operations",
|
||||||
|
parseInt
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--log-level <level>",
|
||||||
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||||
|
"INFO"
|
||||||
|
)
|
||||||
|
.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"
|
||||||
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
|
--log-level DEBUG
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
program.parse(argv);
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
const opts = program.opts();
|
||||||
|
const localPath = opts.localPath as string | undefined;
|
||||||
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
|
const token = opts.token as string | undefined;
|
||||||
|
const vaultName = opts.vaultName as string | undefined;
|
||||||
|
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||||
|
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
|
if (localPath === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"required option '-l, --local-path <path>' not specified"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (remoteUri === undefined) {
|
||||||
|
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||||
|
}
|
||||||
|
if (token === undefined) {
|
||||||
|
throw new Error("required option '--token <token>' not specified");
|
||||||
|
}
|
||||||
|
if (vaultName === undefined) {
|
||||||
|
throw new Error("required option '--vault-name <name>' not specified");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and parse log level
|
||||||
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
|
const validLogLevels = Object.values(LogLevel);
|
||||||
|
const isLogLevel = (value: string): value is LogLevel => {
|
||||||
|
return (validLogLevels as readonly string[]).includes(value);
|
||||||
|
};
|
||||||
|
if (!isLogLevel(logLevelUpper)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
|
return {
|
||||||
|
localPath,
|
||||||
|
remoteUri,
|
||||||
|
token,
|
||||||
|
vaultName,
|
||||||
|
syncConcurrency,
|
||||||
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
|
ignorePatterns: ignorePattern,
|
||||||
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
|
logLevel
|
||||||
|
};
|
||||||
|
}
|
||||||
207
frontend/local-client-cli/src/cli.ts
Normal file
207
frontend/local-client-cli/src/cli.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import {
|
||||||
|
SyncClient,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
LogLevel,
|
||||||
|
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";
|
||||||
|
|
||||||
|
const LOG_LEVEL_ORDER = {
|
||||||
|
[LogLevel.DEBUG]: 0,
|
||||||
|
[LogLevel.INFO]: 1,
|
||||||
|
[LogLevel.WARNING]: 2,
|
||||||
|
[LogLevel.ERROR]: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||||
|
colorize(` v${packageJson.version}`, "dim")
|
||||||
|
);
|
||||||
|
console.log(colorize("=".repeat(50), "dim"));
|
||||||
|
console.log(
|
||||||
|
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||||
|
|
||||||
|
const ignorePatterns = [
|
||||||
|
...(args.ignorePatterns ?? []),
|
||||||
|
".vaultlink/**",
|
||||||
|
".git/**"
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings: SyncSettings = {
|
||||||
|
remoteUri: args.remoteUri,
|
||||||
|
token: args.token,
|
||||||
|
vaultName: args.vaultName,
|
||||||
|
syncConcurrency:
|
||||||
|
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||||
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
|
ignorePatterns,
|
||||||
|
webSocketRetryIntervalMs:
|
||||||
|
args.webSocketRetryIntervalMs ??
|
||||||
|
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
|
||||||
|
isSyncEnabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await SyncClient.create({
|
||||||
|
fs: fileSystem,
|
||||||
|
persistence: {
|
||||||
|
load: async () => {
|
||||||
|
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(dataFile, "utf-8");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
colorize(
|
||||||
|
`Cannot read data file at ${dataFile}`,
|
||||||
|
"yellow"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
database
|
||||||
|
};
|
||||||
|
},
|
||||||
|
save: async ({ database: persistedDatabase }) => {
|
||||||
|
// settings can't be updated when running with this CLI
|
||||||
|
await fs.writeFile(
|
||||||
|
dataFile,
|
||||||
|
JSON.stringify(persistedDatabase, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add colored log formatter with level filtering
|
||||||
|
client.logger.addOnMessageListener((logLine) => {
|
||||||
|
// Only show messages at or above the configured log level
|
||||||
|
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
||||||
|
console.log(formatLogLine(logLine));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
|
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||||
|
|
||||||
|
client.addWebSocketStatusChangeListener(() => {
|
||||||
|
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(colorize("Press Ctrl+C to stop", "dim"));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
await client.start();
|
||||||
|
fileWatcher.start();
|
||||||
|
} 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/**"
|
".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";
|
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
|
const debugOptions = isDebugBuild
|
||||||
? {
|
? {
|
||||||
fetch: debugging.slowFetchFactory(1),
|
fetch: debugging.slowFetchFactory(1),
|
||||||
|
|
|
||||||
39
frontend/package-lock.json
generated
39
frontend/package-lock.json
generated
|
|
@ -8,7 +8,8 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client"
|
"test-client",
|
||||||
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
|
@ -19,6 +20,33 @@
|
||||||
"typescript-eslint": "8.41.0"
|
"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",
|
||||||
|
"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": {
|
"node_modules/@codemirror/state": {
|
||||||
"version": "6.5.2",
|
"version": "6.5.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -1612,6 +1640,8 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-gyp-build": "^4.3.0"
|
"node-gyp-build": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2793,6 +2823,10 @@
|
||||||
"node": ">=8.9.0"
|
"node": ">=8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/local-client-cli": {
|
||||||
|
"resolved": "local-client-cli",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -2995,6 +3029,8 @@
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"node-gyp-build": "bin.js",
|
"node-gyp-build": "bin.js",
|
||||||
"node-gyp-build-optional": "optional.js",
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
|
@ -4658,7 +4694,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"@types/node": "^24.8.1",
|
||||||
"bufferutil": "^4.0.9",
|
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client"
|
"test-client",
|
||||||
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"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"
|
"update": "ncu -u -ws"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "test-client",
|
"name": "test-client",
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"test-client": "./dist/cli.js"
|
"test-client": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack watch --mode development",
|
"dev": "webpack watch --mode development",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test src/**/*.test.ts"
|
"test": "tsx --test src/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"@types/node": "^24.8.1",
|
||||||
"bufferutil": "^4.0.9",
|
"sync-client": "file:../sync-client",
|
||||||
"sync-client": "file:../sync-client",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-loader": "^9.5.2",
|
"tslib": "2.8.1",
|
||||||
"tslib": "2.8.1",
|
"tsx": "^4.20.5",
|
||||||
"tsx": "^4.20.5",
|
"typescript": "5.8.3",
|
||||||
"typescript": "5.8.3",
|
"uuid": "^11.1.0",
|
||||||
"uuid": "^11.1.0",
|
"webpack": "^5.99.9",
|
||||||
"webpack": "^5.99.9",
|
"webpack-cli": "^6.0.1"
|
||||||
"webpack-cli": "^6.0.1"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Success"
|
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
if [[ "$FIX_MODE" == true ]]; then
|
||||||
|
$0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Success"
|
||||||
|
|
|
||||||
|
|
@ -370,11 +370,12 @@ impl Database {
|
||||||
.context("Cannot fetch document version")
|
.context("Cannot fetch document version")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inserting the document must be the last step of the transaction if there's one
|
||||||
pub async fn insert_document_version(
|
pub async fn insert_document_version(
|
||||||
&self,
|
&self,
|
||||||
vault_id: &VaultId,
|
vault_id: &VaultId,
|
||||||
version: &StoredDocumentVersion,
|
version: &StoredDocumentVersion,
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
transaction: Option<Transaction<'_>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let document_id = version.document_id.as_hyphenated();
|
let document_id = version.document_id.as_hyphenated();
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
|
|
@ -401,14 +402,22 @@ impl Database {
|
||||||
version.device_id
|
version.device_id
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
if let Some(mut transaction) = transaction {
|
||||||
query.execute(&mut **transaction).await
|
query
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.context("Cannot insert document version")?;
|
||||||
|
|
||||||
|
transaction
|
||||||
|
.commit()
|
||||||
|
.await
|
||||||
|
.context("Failed to commit transaction")?;
|
||||||
} else {
|
} else {
|
||||||
query
|
query
|
||||||
.execute(&self.get_connection_pool(vault_id).await?)
|
.execute(&self.get_connection_pool(vault_id).await?)
|
||||||
.await
|
.await
|
||||||
|
.context("Cannot insert document version")?;
|
||||||
}
|
}
|
||||||
.context("Cannot insert document version")?;
|
|
||||||
|
|
||||||
self.broadcasts
|
self.broadcasts
|
||||||
.send_document_update(
|
.send_document_update(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use anyhow::Context as _;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
|
@ -82,15 +81,9 @@ pub async fn create_document(
|
||||||
|
|
||||||
state
|
state
|
||||||
.database
|
.database
|
||||||
.insert_document_version(&vault_id, &new_version, Some(&mut transaction))
|
.insert_document_version(&vault_id, &new_version, Some(transaction))
|
||||||
.await
|
.await
|
||||||
.map_err(server_error)?;
|
.map_err(server_error)?;
|
||||||
|
|
||||||
transaction
|
|
||||||
.commit()
|
|
||||||
.await
|
|
||||||
.context("Failed to commit successful transaction")
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Ok(Json(new_version.into()))
|
Ok(Json(new_version.into()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use anyhow::Context as _;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
|
@ -71,15 +70,9 @@ pub async fn delete_document(
|
||||||
|
|
||||||
state
|
state
|
||||||
.database
|
.database
|
||||||
.insert_document_version(&vault_id, &new_version, Some(&mut transaction))
|
.insert_document_version(&vault_id, &new_version, Some(transaction))
|
||||||
.await
|
.await
|
||||||
.map_err(server_error)?;
|
.map_err(server_error)?;
|
||||||
|
|
||||||
transaction
|
|
||||||
.commit()
|
|
||||||
.await
|
|
||||||
.context("Failed to commit successful transaction")
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Ok(Json(new_version.into()))
|
Ok(Json(new_version.into()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,16 +183,10 @@ pub async fn update_document(
|
||||||
|
|
||||||
state
|
state
|
||||||
.database
|
.database
|
||||||
.insert_document_version(&vault_id, &new_version, Some(&mut transaction))
|
.insert_document_version(&vault_id, &new_version, Some(transaction))
|
||||||
.await
|
.await
|
||||||
.map_err(server_error)?;
|
.map_err(server_error)?;
|
||||||
|
|
||||||
transaction
|
|
||||||
.commit()
|
|
||||||
.await
|
|
||||||
.context("Failed to commit successful transaction")
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Ok(Json(if is_different_from_request_content {
|
Ok(Json(if is_different_from_request_content {
|
||||||
DocumentUpdateResponse::MergingUpdate(new_version.into())
|
DocumentUpdateResponse::MergingUpdate(new_version.into())
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue