From 90752e687ad1306fdbd63c7f356a2372ab6be6be Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 22:45:47 +0100 Subject: [PATCH] Add local CLI (#144) --- .github/workflows/publish-cli-docker.yml | 64 +++++ ...h-docker.yml => publish-server-docker.yml} | 0 frontend/local-client-cli/.dockerignore | 9 + frontend/local-client-cli/Dockerfile | 25 ++ frontend/local-client-cli/README.md | 206 ++++++++++++++++ frontend/local-client-cli/package.json | 27 ++ frontend/local-client-cli/src/args.test.ts | 230 ++++++++++++++++++ frontend/local-client-cli/src/args.ts | 122 ++++++++++ frontend/local-client-cli/src/cli.ts | 207 ++++++++++++++++ frontend/local-client-cli/src/file-watcher.ts | 102 ++++++++ .../local-client-cli/src/logger-formatter.ts | 86 +++++++ .../src/node-filesystem.test.ts | 162 ++++++++++++ .../local-client-cli/src/node-filesystem.ts | 203 ++++++++++++++++ frontend/local-client-cli/tsconfig.json | 20 ++ frontend/local-client-cli/webpack.config.js | 30 +++ .../obsidian-plugin/src/vault-link-plugin.ts | 83 +++---- frontend/package-lock.json | 39 ++- frontend/package.json | 5 +- frontend/test-client/package.json | 45 ++-- scripts/check.sh | 7 +- sync-server/src/app_state/database.rs | 17 +- sync-server/src/server/create_document.rs | 9 +- sync-server/src/server/delete_document.rs | 9 +- sync-server/src/server/update_document.rs | 8 +- 24 files changed, 1616 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/publish-cli-docker.yml rename .github/workflows/{publish-docker.yml => publish-server-docker.yml} (100%) create mode 100644 frontend/local-client-cli/.dockerignore create mode 100644 frontend/local-client-cli/Dockerfile create mode 100644 frontend/local-client-cli/README.md create mode 100644 frontend/local-client-cli/package.json create mode 100644 frontend/local-client-cli/src/args.test.ts create mode 100644 frontend/local-client-cli/src/args.ts create mode 100644 frontend/local-client-cli/src/cli.ts create mode 100644 frontend/local-client-cli/src/file-watcher.ts create mode 100644 frontend/local-client-cli/src/logger-formatter.ts create mode 100644 frontend/local-client-cli/src/node-filesystem.test.ts create mode 100644 frontend/local-client-cli/src/node-filesystem.ts create mode 100644 frontend/local-client-cli/tsconfig.json create mode 100644 frontend/local-client-cli/webpack.config.js diff --git a/.github/workflows/publish-cli-docker.yml b/.github/workflows/publish-cli-docker.yml new file mode 100644 index 00000000..73ef1b12 --- /dev/null +++ b/.github/workflows/publish-cli-docker.yml @@ -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} diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-server-docker.yml similarity index 100% rename from .github/workflows/publish-docker.yml rename to .github/workflows/publish-server-docker.yml diff --git a/frontend/local-client-cli/.dockerignore b/frontend/local-client-cli/.dockerignore new file mode 100644 index 00000000..0b642eb3 --- /dev/null +++ b/frontend/local-client-cli/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +*.log +.git +.gitignore +README.md +*.test.ts +coverage +.vaultlink diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile new file mode 100644 index 00000000..6b8e1d6c --- /dev/null +++ b/frontend/local-client-cli/Dockerfile @@ -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"] diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md new file mode 100644 index 00000000..0585bacc --- /dev/null +++ b/frontend/local-client-cli/README.md @@ -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 ` | Local directory to sync | +| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | +| `-t, --token ` | Authentication token | +| `-v, --vault-name ` | Vault name on server | + +### Optional + +| Option | Default | Description | +|--------|---------|-------------| +| `--sync-concurrency ` | `1` | Concurrent sync operations | +| `--max-file-size-mb ` | `10` | Maximum file size in MB | +| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | +| `--log-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 diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json new file mode 100644 index 00000000..e03d2454 --- /dev/null +++ b/frontend/local-client-cli/package.json @@ -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" + } +} diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts new file mode 100644 index 00000000..206e39b7 --- /dev/null +++ b/frontend/local-client-cli/src/args.test.ts @@ -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/); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts new file mode 100644 index 00000000..08ef2a6b --- /dev/null +++ b/frontend/local-client-cli/src/args.ts @@ -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 ", "Local directory path to sync") + .option("-r, --remote-uri ", "Remote server URI") + .option("-t, --token ", "Authentication token") + .option("-v, --vault-name ", "Vault name") + .option( + "--sync-concurrency ", + "[OPTIONAL] Number of concurrent sync operations", + parseInt + ) + .option( + "--max-file-size-mb ", + "[OPTIONAL] Maximum file size in MB", + parseInt + ) + .option( + "--ignore-pattern ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ) + .option( + "--websocket-retry-interval-ms ", + "[OPTIONAL] WebSocket retry interval in milliseconds", + parseInt + ) + .option( + "--log-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 ' not specified" + ); + } + if (remoteUri === undefined) { + throw new Error("required option '--remote-uri ' not specified"); + } + if (token === undefined) { + throw new Error("required option '--token ' not specified"); + } + if (vaultName === undefined) { + throw new Error("required option '--vault-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 + }; +} diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts new file mode 100644 index 00000000..5a3c6546 --- /dev/null +++ b/frontend/local-client-cli/src/cli.ts @@ -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 { + 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 | 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; + } 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 => { + 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); +}); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts new file mode 100644 index 00000000..65577bc4 --- /dev/null +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -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; + } +} diff --git a/frontend/local-client-cli/src/logger-formatter.ts b/frontend/local-client-cli/src/logger-formatter.ts new file mode 100644 index 00000000..994adc74 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.ts @@ -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}`; +} diff --git a/frontend/local-client-cli/src/node-filesystem.test.ts b/frontend/local-client-cli/src/node-filesystem.test.ts new file mode 100644 index 00000000..4a72da94 --- /dev/null +++ b/frontend/local-client-cli/src/node-filesystem.test.ts @@ -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 }); + } +}); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts new file mode 100644 index 00000000..252385c9 --- /dev/null +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -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 { + const files: RelativePath[] = []; + await this.walkDirectory( + directory !== undefined ? this.toNativePath(directory) : "", + files + ); + return files; + } + + public async read(relativePath: RelativePath): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/frontend/local-client-cli/tsconfig.json b/frontend/local-client-cli/tsconfig.json new file mode 100644 index 00000000..cfd2df7f --- /dev/null +++ b/frontend/local-client-cli/tsconfig.json @@ -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"] +} diff --git a/frontend/local-client-cli/webpack.config.js b/frontend/local-client-cli/webpack.config.js new file mode 100644 index 00000000..e17754b2 --- /dev/null +++ b/frontend/local-client-cli/webpack.config.js @@ -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 }) + ] +}; diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index ce3f23ac..25a03ff6 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -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), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4df70bd5..6536a81e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "sync-client", "obsidian-plugin", - "test-client" + "test-client", + "local-client-cli" ], "devDependencies": { "concurrently": "^9.2.1", @@ -19,6 +20,33 @@ "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": { "version": "6.5.2", "dev": true, @@ -1612,6 +1640,8 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -2793,6 +2823,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, @@ -2995,6 +3029,8 @@ "version": "4.8.4", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -4658,7 +4694,6 @@ }, "devDependencies": { "@types/node": "^24.8.1", - "bufferutil": "^4.0.9", "sync-client": "file:../sync-client", "ts-loader": "^9.5.2", "tslib": "2.8.1", diff --git a/frontend/package.json b/frontend/package.json index ceb1a3f3..e105b2fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index fbcb509d..7314cf18 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -1,25 +1,24 @@ { - "name": "test-client", - "version": "0.9.0", - "private": true, - "bin": { - "test-client": "./dist/cli.js" - }, - "scripts": { - "dev": "webpack watch --mode development", - "build": "webpack --mode production", - "test": "tsx --test src/**/*.test.ts" - }, - "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", - "uuid": "^11.1.0", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } + "name": "test-client", + "version": "0.9.0", + "private": true, + "bin": { + "test-client": "./dist/cli.js" + }, + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "tsx --test src/**/*.test.ts" + }, + "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", + "uuid": "^11.1.0", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } } diff --git a/scripts/check.sh b/scripts/check.sh index 576ed0ec..0a28653c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -36,6 +36,11 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then exit 1 fi -echo "Success" cd .. + +if [[ "$FIX_MODE" == true ]]; then + $0 +fi + +echo "Success" diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 2fc47ccb..346fea38 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -370,11 +370,12 @@ impl Database { .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( &self, vault_id: &VaultId, version: &StoredDocumentVersion, - transaction: Option<&mut Transaction<'_>>, + transaction: Option>, ) -> Result<()> { let document_id = version.document_id.as_hyphenated(); let query = sqlx::query!( @@ -401,14 +402,22 @@ impl Database { version.device_id ); - if let Some(transaction) = transaction { - query.execute(&mut **transaction).await + if let Some(mut transaction) = transaction { + query + .execute(&mut *transaction) + .await + .context("Cannot insert document version")?; + + transaction + .commit() + .await + .context("Failed to commit transaction")?; } else { query .execute(&self.get_connection_pool(vault_id).await?) .await + .context("Cannot insert document version")?; } - .context("Cannot insert document version")?; self.broadcasts .send_document_update( diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index d8083410..0f698538 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -1,4 +1,3 @@ -use anyhow::Context as _; use axum::{ Extension, Json, extract::{Path, State}, @@ -82,15 +81,9 @@ pub async fn create_document( state .database - .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .map_err(server_error)?; - transaction - .commit() - .await - .context("Failed to commit successful transaction") - .map_err(server_error)?; - Ok(Json(new_version.into())) } diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index fa9d578c..f7080417 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,4 +1,3 @@ -use anyhow::Context as _; use axum::{ Extension, Json, extract::{Path, State}, @@ -71,15 +70,9 @@ pub async fn delete_document( state .database - .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .map_err(server_error)?; - transaction - .commit() - .await - .context("Failed to commit successful transaction") - .map_err(server_error)?; - Ok(Json(new_version.into())) } diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index 04ba8b63..bf11504c 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -183,16 +183,10 @@ pub async fn update_document( state .database - .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .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 { DocumentUpdateResponse::MergingUpdate(new_version.into()) } else {