From 116661c674666f498d6ecb8e9e7cb6ccf080b55c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 19 Oct 2025 16:10:41 +0100 Subject: [PATCH 01/15] Delete empty folders during syncing --- .../src/obsidian-file-system.ts | 6 ++- .../file-operations/file-operations.test.ts | 4 +- .../src/file-operations/file-operations.ts | 51 +++++++++++++++---- .../file-operations/filesystem-operations.ts | 6 ++- .../safe-filesystem-operations.ts | 6 ++- .../sync-client/src/sync-operations/syncer.ts | 4 +- frontend/test-client/src/agent/mock-agent.ts | 2 +- frontend/test-client/src/agent/mock-client.ts | 5 +- 8 files changed, 64 insertions(+), 20 deletions(-) diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 00a9acfb..44407890 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -14,10 +14,12 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { private readonly workspace: Workspace ) {} - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. const allFiles = []; - const remainingFolders = [this.vault.getRoot().path]; + const remainingFolders = [root ?? this.vault.getRoot().path]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 64c02655..7a7aa959 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -29,7 +29,9 @@ class MockDatabase implements Partial { class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); - public async listAllFiles(): Promise { + public async listFilesRecursively( + _root: RelativePath | undefined + ): Promise { throw new Error("Method not implemented."); } public async read(_path: RelativePath): Promise { diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 38f624e5..ff971889 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -5,6 +5,7 @@ import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { isBinary, reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; + export class FileOperations { private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private readonly fs: SafeFileSystemOperations; @@ -18,8 +19,22 @@ export class FileOperations { this.fs = new SafeFileSystemOperations(fs, logger); } - public async listAllFiles(): Promise { - return this.fs.listAllFiles(); + private static getParentDirAndFile( + path: RelativePath + ): [RelativePath, RelativePath] { + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + if (fileName == "" || fileName == null) { + throw new Error(`Path '${path}' cannot be empty`); + } + + return [pathParts.join("/"), fileName]; + } + + public async listFilesRecursively( + root: RelativePath | undefined = undefined + ): Promise { + return this.fs.listFilesRecursively(root); } public async read(path: RelativePath): Promise { @@ -120,7 +135,8 @@ export class FileOperations { public async delete(path: RelativePath): Promise { if (await this.exists(path)) { - return this.fs.delete(path); + await this.fs.delete(path); + await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { this.logger.debug(`No need to delete '${path}', it doesn't exist`); } @@ -146,6 +162,27 @@ export class FileOperations { this.database.move(oldPath, newPath); await this.fs.rename(oldPath, newPath); + await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); + } + + private async deletingEmptyParentDirectoriesOfDeletedFile( + path: RelativePath + ): Promise { + let directory = path; + while (directory.length > 1) { + [directory] = FileOperations.getParentDirAndFile(directory); + + const remainingContent = + await this.fs.listFilesRecursively(directory); + if (remainingContent.length == 0) { + this.logger.debug( + `Folder (${directory}) is now empty, deleting` + ); + await this.fs.delete(directory); + } else { + break; + } + } } private fromNativeLineEndings(content: Uint8Array): Uint8Array { @@ -184,13 +221,9 @@ export class FileOperations { } private async deconflictPath(path: RelativePath): Promise { - const pathParts = path.split("/"); - const fileName = pathParts.pop(); - if (fileName == "" || fileName == null) { - throw new Error(`Path '${path}' cannot be empty`); - } + // eslint-disable-next-line prefer-const + let [directory, fileName] = FileOperations.getParentDirAndFile(path); - let directory = pathParts.join("/"); if (directory) { directory += "/"; } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index d5d1eedc..9c7a8366 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -3,8 +3,10 @@ import type { RelativePath } from "../persistence/database"; import type { TextWithCursors } from "reconcile-text"; export interface FileSystemOperations { - // List all files that should be synced. - listAllFiles: () => Promise; + // List all files under root that should be synced. If root is undefined, return every file. + listFilesRecursively: ( + root: RelativePath | undefined + ) => Promise; // Read the content of a file. read: (path: RelativePath) => Promise; diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 2b1f908a..2c865c9f 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -20,9 +20,11 @@ export class SafeFileSystemOperations implements FileSystemOperations { this.locks = new Locks(logger); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { this.logger.debug("Listing all files"); - const result = await this.fs.listAllFiles(); + const result = await this.fs.listFilesRecursively(root); this.logger.debug(`Listed ${result.length} files`); return result; } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 186b9a9b..03041a36 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -335,7 +335,7 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { await this.createFakeDocumentsFromRemoteState(); - const allLocalFiles = await this.operations.listAllFiles(); + const allLocalFiles = await this.operations.listFilesRecursively(); let locallyPossiblyDeletedFiles: DocumentRecord[] = []; @@ -431,7 +431,7 @@ export class Syncer { } const [allLocalFiles, remote] = await Promise.all([ - this.operations.listAllFiles(), + this.operations.listFilesRecursively(), this.syncQueue.add(async () => this.syncService.getAll()) ]); diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 9e7806ab..a6ced45d 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -94,7 +94,7 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } - const files = await this.listAllFiles(); + const files = await this.listFilesRecursively(); if (files.length > 0) { options.push( diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 3ef55c8f..2b384c24 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -7,6 +7,7 @@ import { SyncClient } from "sync-client"; import type { TextWithCursors } from "reconcile-text"; + export class MockClient implements FileSystemOperations { protected readonly localFiles = new Map(); protected client!: SyncClient; @@ -46,7 +47,9 @@ export class MockClient implements FileSystemOperations { await this.client.start(); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { return Array.from(this.localFiles.keys()); } -- 2.47.2 From b8e862cb673c47bef707be37b9f78ebdf73bdf98 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 19 Oct 2025 16:11:13 +0100 Subject: [PATCH 02/15] Fix dockerfile --- sync-server/Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index 9d157520..9993ecd8 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -8,7 +8,6 @@ RUN apt update && \ pkg-config && \ cargo install sqlx-cli -# Build application COPY . . RUN sqlx database create --database-url sqlite://db.sqlite3 && \ @@ -28,7 +27,6 @@ RUN apt update && \ rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/src/backend/target/release/sync_server /app/sync_server -COPY test-entrypoint.sh /app/test-entrypoint.sh RUN chmod +x /app/test-entrypoint.sh -- 2.47.2 From 59568402170f0871d245a8d186f78c9a53276bd0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 19 Oct 2025 19:57:52 +0100 Subject: [PATCH 03/15] Fix tests --- .../sync-client/src/file-operations/file-operations.test.ts | 2 +- sync-server/Dockerfile | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 7a7aa959..675fdce1 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -32,7 +32,7 @@ class FakeFileSystemOperations implements FileSystemOperations { public async listFilesRecursively( _root: RelativePath | undefined ): Promise { - throw new Error("Method not implemented."); + return ["file.md"]; } public async read(_path: RelativePath): Promise { throw new Error("Method not implemented."); diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index 9993ecd8..cfb76138 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -28,8 +28,6 @@ RUN apt update && \ COPY --from=builder /usr/src/backend/target/release/sync_server /app/sync_server -RUN chmod +x /app/test-entrypoint.sh - VOLUME /data EXPOSE 3000/tcp WORKDIR /data -- 2.47.2 From d1c4b319a592bfb6957cb91379be501e20534a26 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 19 Oct 2025 20:20:43 +0100 Subject: [PATCH 04/15] Fix deletion logic --- .../sync-client/src/file-operations/file-operations.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index ff971889..56ce0e51 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -169,12 +169,16 @@ export class FileOperations { path: RelativePath ): Promise { let directory = path; - while (directory.length > 1) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { [directory] = FileOperations.getParentDirAndFile(directory); + if (directory.length === 0) { + break; + } const remainingContent = await this.fs.listFilesRecursively(directory); - if (remainingContent.length == 0) { + if (remainingContent.length === 0) { this.logger.debug( `Folder (${directory}) is now empty, deleting` ); -- 2.47.2 From b160dadf4491b7839dbf47f6a12e8f2629369e40 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 20:22:29 +0100 Subject: [PATCH 05/15] Implement --- .github/workflows/publish-cli.yml | 102 +++++++ frontend/local-client-cli/.dockerignore | 9 + frontend/local-client-cli/Dockerfile | 58 ++++ frontend/local-client-cli/README.md | 288 ++++++++++++++++++ frontend/local-client-cli/package.json | 28 ++ frontend/local-client-cli/src/args.test.ts | 136 +++++++++ frontend/local-client-cli/src/args.ts | 103 +++++++ frontend/local-client-cli/src/cli.ts | 206 +++++++++++++ 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 | 35 ++- frontend/package.json | 5 +- scripts/check.sh | 7 +- 18 files changed, 1615 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/publish-cli.yml 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.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 00000000..f439246d --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,102 @@ +name: Publish CLI + +on: + push: + tags: ["*"] + pull_request: + branches: ["main"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}-cli + +jobs: + publish-docker: + runs-on: self-hosted + + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: "v2.2.4" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: frontend + file: frontend/local-client-cli/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Sign the published Docker image + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + + publish-npm: + runs-on: self-hosted + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js environment + uses: actions/setup-node@v4.2.0 + with: + node-version: "22.x" + check-latest: true + registry-url: 'https://npm.pkg.github.com' + scope: '@schmelczer' + + - name: Install dependencies + run: | + cd frontend + npm ci + + - name: Build CLI + run: | + cd frontend/local-client-cli + npm run build + + - name: Publish to GitHub Packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd frontend/local-client-cli + # Update package.json to use scoped name for GitHub Packages + npm version --no-git-tag-version ${GITHUB_REF#refs/tags/} + npm publish 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..7857ca23 --- /dev/null +++ b/frontend/local-client-cli/Dockerfile @@ -0,0 +1,58 @@ +FROM node:22-alpine AS builder + +WORKDIR /build + +# Copy workspace root package files +COPY package*.json ./ + +# Copy package.json files for each workspace +COPY sync-client/package*.json ./sync-client/ +COPY local-client-cli/package*.json ./local-client-cli/ + +# Install dependencies +RUN npm ci --workspaces + +# Copy source code +COPY sync-client/ ./sync-client/ +COPY local-client-cli/ ./local-client-cli/ + +WORKDIR /build/sync-client +RUN npm run build + +WORKDIR /build/local-client-cli +RUN npm run build + +# Stage 2: Runtime image +FROM node:22-alpine + +# Add labels for metadata +LABEL org.opencontainers.image.title="VaultLink Local CLI" +LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" +LABEL org.opencontainers.image.source="https://github.com/schmelczer/vault-link" +LABEL org.opencontainers.image.licenses="MIT" + +# Create non-root user +RUN addgroup -g 1001 vaultlink && \ + adduser -D -u 1001 -G vaultlink vaultlink + +# Create vault directory +RUN mkdir -p /vault && \ + chown -R vaultlink:vaultlink /vault + +# Copy only the built CLI +COPY --from=builder --chown=vaultlink:vaultlink /build/local-client-cli/dist/cli.js /app/cli.js + +# Switch to non-root user +USER vaultlink + +# Set working directory to vault +WORKDIR /vault + +# Volume for vault data +VOLUME ["/vault"] + +# Entry point +ENTRYPOINT ["node", "/app/cli.js"] + +# Default: show help +CMD ["--help"] diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md new file mode 100644 index 00000000..42a036b2 --- /dev/null +++ b/frontend/local-client-cli/README.md @@ -0,0 +1,288 @@ +# VaultLink Local CLI + +A standalone command-line interface for syncing VaultLink vaults to your local filesystem. This CLI wraps the VaultLink sync client and provides file watching capabilities for real-time synchronization. + +## Features + +- Real-time bidirectional sync between local filesystem and VaultLink server +- File watching with automatic change detection +- Cross-platform support (Linux, macOS, Windows) +- Configuration via command-line arguments or JSON file +- Comprehensive error handling and logging +- Graceful shutdown on SIGINT/SIGTERM + +## Installation + +### Using Docker (Recommended) + +The easiest way to run VaultLink CLI is using Docker: + +```bash +docker pull ghcr.io/schmelczer/vault-link-cli:latest + +# Run with all options via command line +docker run -v /path/to/vault:/vault \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault \ + -r https://sync.example.com \ + -t your-auth-token \ + -v default + +# Or use a config file +docker run -v /path/to/vault:/vault \ + -v /path/to/config.json:/config.json \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault \ + -c /config.json +``` + +### Using Docker Compose + +```yaml +version: '3.8' + +services: + vaultlink-cli: + image: ghcr.io/schmelczer/vault-link-cli:latest + volumes: + - ./vault:/vault + - ./config.json:/config.json + command: -l /vault -c /config.json + restart: unless-stopped +``` + +### From npm Package + +```bash +npm install -g @schmelczer/local-client-cli +vaultlink --help +``` + +### From Source + +```bash +cd frontend/local-client-cli +npm install +npm run build +node dist/cli.js --help +``` + +## Usage + +### Basic Usage + +```bash +vaultlink \ + --local-path ./my-vault \ + --remote-uri https://sync.example.com \ + --token your-auth-token \ + --vault-name default +``` + +### Using a Configuration File + +Create a `config.json` file: + +```json +{ + "remoteUri": "https://sync.example.com", + "token": "your-auth-token", + "vaultName": "default", + "syncConcurrency": 1, + "maxFileSizeMB": 10, + "ignorePatterns": [".git/**", "*.tmp"], + "webSocketRetryIntervalMs": 3500 +} +``` + +Then run: + +```bash +vaultlink --local-path ./my-vault --config config.json +``` + +### Command-Line Options + +#### Required Arguments + +- `-l, --local-path ` - Local directory path to sync +- `-r, --remote-uri ` - Remote server URI (unless using --config) +- `-t, --token ` - Authentication token (unless using --config) +- `-v, --vault-name ` - Vault name (unless using --config) + +#### Optional Arguments + +- `-c, --config ` - Load configuration from JSON file +- `--sync-concurrency ` - Number of concurrent sync operations (default: 1) +- `--max-file-size-mb ` - Maximum file size in MB (default: 10) +- `--ignore-pattern ` - Pattern to ignore (can be used multiple times) +- `--websocket-retry-interval-ms ` - WebSocket retry interval in ms (default: 3500) +- `-h, --help` - Print help message +- `-V, --version` - Print version + +### Ignore Patterns + +Ignore patterns support glob syntax. The CLI automatically ignores: + +- `.vaultlink/**` - Internal sync data directory +- `.git/**` - Git repository files + +You can add additional patterns: + +```bash +vaultlink \ + --local-path ./my-vault \ + --remote-uri https://sync.example.com \ + --token mytoken \ + --vault-name default \ + --ignore-pattern "*.tmp" \ + --ignore-pattern ".DS_Store" \ + --ignore-pattern "node_modules/**" +``` + +## Docker Deployment + +### Self-Hosting with Docker + +The CLI is designed to run as a long-lived container: + +```bash +# Create a vault directory +mkdir -p ./vault + +# Create config.json +cat > config.json < { + 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/); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts new file mode 100644 index 00000000..01e76fad --- /dev/null +++ b/frontend/local-client-cli/src/args.ts @@ -0,0 +1,103 @@ +import { Command } from "commander"; +import packageJson from "../package.json"; + +export interface CliArgs { + remoteUri: string; + token: string; + vaultName: string; + localPath: string; + syncConcurrency?: number; + maxFileSizeMB?: number; + ignorePatterns?: string[]; + webSocketRetryIntervalMs?: number; +} + +export function parseArgs(argv: string[]): CliArgs { + const program = new Command(); + + program + .name("vaultlink") + .description( + "VaultLink Local CLI - Sync your vault to the local filesystem" + ) + .version(packageJson.version) + .exitOverride((err) => { + // Let help and version exit normally + if ( + err.code === "commander.helpDisplayed" || + err.code === "commander.version" + ) { + process.exit(0); + } + throw err; + }) + .requiredOption( + "-l, --local-path ", + "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 + ) + .addHelpText( + "after", + ` +Examples: + $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default + $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ + --ignore-pattern ".git/**" --ignore-pattern "*.tmp" +` + ); + + program.parse(argv); + + const options = program.opts<{ + localPath: string; + remoteUri?: string; + token?: string; + vaultName?: string; + syncConcurrency?: number; + maxFileSizeMb?: number; + ignorePattern?: string[]; + websocketRetryIntervalMs?: number; + }>(); + + if (options.remoteUri === undefined) { + throw new Error("required option '--remote-uri ' not specified"); + } + if (options.token === undefined) { + throw new Error("required option '--token ' not specified"); + } + if (options.vaultName === undefined) { + throw new Error("required option '--vault-name ' not specified"); + } + + return { + localPath: options.localPath, + remoteUri: options.remoteUri ?? "", + token: options.token ?? "", + vaultName: options.vaultName ?? "", + syncConcurrency: options.syncConcurrency, + maxFileSizeMB: options.maxFileSizeMb, + ignorePatterns: options.ignorePattern, + webSocketRetryIntervalMs: options.websocketRetryIntervalMs + }; +} diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts new file mode 100644 index 00000000..8f0e367d --- /dev/null +++ b/frontend/local-client-cli/src/cli.ts @@ -0,0 +1,206 @@ +import * as path from "path"; +import * as fs from "fs/promises"; +import { + SyncClient, + DEFAULT_SETTINGS, + type SyncSettings, + type StoredDatabase +} from "sync-client"; +import { parseArgs } from "./args"; +import { NodeFileSystemOperations } from "./node-filesystem"; +import { FileWatcher } from "./file-watcher"; +import { formatLogLine, colorize, styleText } from "./logger-formatter"; +import packageJson from "../package.json"; + +async function main(): Promise { + const args = parseArgs(process.argv); + const absolutePath = path.resolve(args.localPath); + + try { + const stats = await fs.stat(absolutePath); + if (!stats.isDirectory()) { + console.error( + colorize(`Error: ${absolutePath} is not a directory`, "red") + ); + process.exit(1); + } + } catch (error) { + console.error( + colorize( + `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, + "red" + ) + ); + process.exit(1); + } + + // Print header with colors + console.log( + styleText("VaultLink Local CLI", "bold", "cyan") + + colorize(` v${packageJson.version}`, "dim") + ); + console.log(colorize("=".repeat(50), "dim")); + console.log( + `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` + ); + console.log( + `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` + ); + console.log( + `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` + ); + console.log(""); + + const dataDir = path.join(absolutePath, ".vaultlink"); + const dataFile = path.join(dataDir, "sync-data.json"); + + await fs.mkdir(dataDir, { recursive: true }); + + const fileSystem = new NodeFileSystemOperations(absolutePath); + + const ignorePatterns = [ + ...(args.ignorePatterns ?? []), + ".vaultlink/**", + ".git/**" + ]; + + const settings: SyncSettings = { + remoteUri: args.remoteUri, + token: args.token, + vaultName: args.vaultName, + syncConcurrency: + args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, + maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, + ignorePatterns, + webSocketRetryIntervalMs: + args.webSocketRetryIntervalMs ?? + DEFAULT_SETTINGS.webSocketRetryIntervalMs, + isSyncEnabled: true + }; + + const client = await SyncClient.create({ + fs: fileSystem, + persistence: { + load: async () => { + let database: Partial | 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 + client.logger.addOnMessageListener((logLine) => { + console.log(formatLogLine(logLine)); + }); + + client.logger.info("Starting sync client"); + + const fileWatcher = new FileWatcher(absolutePath, client); + + client.addWebSocketStatusChangeListener(() => { + const currentSettings = client.getSettings(); + if (currentSettings.isSyncEnabled) { + client.logger.info("WebSocket status changed"); + } + }); + + client.addRemainingSyncOperationsListener((remaining) => { + if (remaining === 0) { + client.logger.info("All sync operations completed"); + } else { + client.logger.info(`${remaining} sync operations remaining`); + } + }); + + const gracefulShutdown = async (signal: string): Promise => { + console.log( + colorize( + `\n${signal} received. Shutting down gracefully...`, + "yellow" + ) + ); + fileWatcher.stop(); + await client.waitAndStop(); + console.log(colorize("Shutdown complete", "green")); + process.exit(0); + }; + + process.on("SIGINT", () => { + void gracefulShutdown("SIGINT"); + }); + process.on("SIGTERM", () => { + void gracefulShutdown("SIGTERM"); + }); + + try { + const connectionStatus = await client.checkConnection(); + if (!connectionStatus.isSuccessful) { + console.error( + colorize( + `Error: Cannot connect to server: ${connectionStatus.serverMessage}`, + "red" + ) + ); + process.exit(1); + } + + console.log(`${colorize("✓", "green")} Server connection successful`); + console.log(""); + + await client.start(); + fileWatcher.start(); + + console.log(styleText("✓ Sync started", "bold", "green")); + console.log(colorize("Press Ctrl+C to stop", "dim")); + console.log(colorize("─".repeat(50), "dim")); + console.log(""); + + await new Promise(() => { + // Keep process alive until signal received + }); + } catch (error) { + console.error( + colorize( + `Fatal error: ${error instanceof Error ? error.message : String(error)}`, + "red" + ) + ); + fileWatcher.stop(); + await client.waitAndStop(); + process.exit(1); + } +} + +main().catch((error: unknown) => { + console.error( + colorize( + `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + "red" + ) + ); + process.exit(1); +}); 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..b2324b9b --- /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 9fb471d8..f6144bb1 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,34 @@ "typescript-eslint": "8.41.0" } }, + "local-client-cli": { + "version": "0.8.2", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "vaultlink": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.8.1", + "bufferutil": "^4.0.9", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.5", + "typescript": "5.8.3", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } + }, + "local-client-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "engines": { + "node": ">=18" + } + }, "node_modules/@codemirror/state": { "version": "6.5.2", "dev": true, @@ -2793,6 +2822,10 @@ "node": ">=8.9.0" } }, + "node_modules/local-client-cli": { + "resolved": "local-client-cli", + "link": true + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, 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/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" -- 2.47.2 From 84855d6a5cb8510712ffdaa1399533655f3b78c3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 21:46:29 +0100 Subject: [PATCH 06/15] Fix insertion race condition --- 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 +------- 4 files changed, 16 insertions(+), 27 deletions(-) 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 { -- 2.47.2 From ca7f56c2e5f30fce0c339a6501686b47dc5ff608 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 21:57:49 +0100 Subject: [PATCH 07/15] Fix docker build --- frontend/local-client-cli/Dockerfile | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 7857ca23..163a37bc 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -1,18 +1,14 @@ -FROM node:22-alpine AS builder +FROM node:22-slim AS builder WORKDIR /build -# Copy workspace root package files COPY package*.json ./ -# Copy package.json files for each workspace COPY sync-client/package*.json ./sync-client/ COPY local-client-cli/package*.json ./local-client-cli/ -# Install dependencies RUN npm ci --workspaces -# Copy source code COPY sync-client/ ./sync-client/ COPY local-client-cli/ ./local-client-cli/ @@ -22,37 +18,20 @@ RUN npm run build WORKDIR /build/local-client-cli RUN npm run build -# Stage 2: Runtime image FROM node:22-alpine -# Add labels for metadata LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" LABEL org.opencontainers.image.source="https://github.com/schmelczer/vault-link" LABEL org.opencontainers.image.licenses="MIT" -# Create non-root user -RUN addgroup -g 1001 vaultlink && \ - adduser -D -u 1001 -G vaultlink vaultlink -# Create vault directory -RUN mkdir -p /vault && \ - chown -R vaultlink:vaultlink /vault -# Copy only the built CLI COPY --from=builder --chown=vaultlink:vaultlink /build/local-client-cli/dist/cli.js /app/cli.js -# Switch to non-root user -USER vaultlink - -# Set working directory to vault WORKDIR /vault -# Volume for vault data VOLUME ["/vault"] -# Entry point ENTRYPOINT ["node", "/app/cli.js"] - -# Default: show help CMD ["--help"] -- 2.47.2 From c3bed7419a161ef68a3a903a0f225bdb0a54a0dd Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 22:06:05 +0100 Subject: [PATCH 08/15] Remove clutter --- frontend/local-client-cli/package.json | 51 +++++++++++++------------- frontend/package-lock.json | 1 - 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index 81c163ad..e03d2454 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -1,28 +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", - "bufferutil": "^4.0.9", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } + "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/package-lock.json b/frontend/package-lock.json index e58d5e7c..2c4f2552 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,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", -- 2.47.2 From 8fb43c1cbd439e43cb5a479824a4891048728c72 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 20:40:59 +0100 Subject: [PATCH 09/15] Simplify cli build --- frontend/local-client-cli/Dockerfile | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 163a37bc..36a314d0 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -2,20 +2,9 @@ FROM node:22-slim AS builder WORKDIR /build -COPY package*.json ./ +COPY . . -COPY sync-client/package*.json ./sync-client/ -COPY local-client-cli/package*.json ./local-client-cli/ - -RUN npm ci --workspaces - -COPY sync-client/ ./sync-client/ -COPY local-client-cli/ ./local-client-cli/ - -WORKDIR /build/sync-client -RUN npm run build - -WORKDIR /build/local-client-cli +RUN npm ci RUN npm run build FROM node:22-alpine @@ -24,6 +13,7 @@ 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" -- 2.47.2 From 27adbf666047b8474763148b3ff76ad596a9a8d9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 20:50:21 +0100 Subject: [PATCH 10/15] Rename files --- ...publish-cli.yml => publish-cli-docker.yml} | 40 +---------------- .github/workflows/publish-npm.yml | 44 +++++++++++++++++++ ...h-docker.yml => publish-server-docker.yml} | 0 3 files changed, 45 insertions(+), 39 deletions(-) rename .github/workflows/{publish-cli.yml => publish-cli-docker.yml} (65%) create mode 100644 .github/workflows/publish-npm.yml rename .github/workflows/{publish-docker.yml => publish-server-docker.yml} (100%) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli-docker.yml similarity index 65% rename from .github/workflows/publish-cli.yml rename to .github/workflows/publish-cli-docker.yml index f439246d..73ef1b12 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli-docker.yml @@ -50,7 +50,7 @@ jobs: with: context: frontend file: frontend/local-client-cli/Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -62,41 +62,3 @@ jobs: TAGS: ${{ steps.meta.outputs.tags }} DIGEST: ${{ steps.build-and-push.outputs.digest }} run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} - - publish-npm: - runs-on: self-hosted - - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "22.x" - check-latest: true - registry-url: 'https://npm.pkg.github.com' - scope: '@schmelczer' - - - name: Install dependencies - run: | - cd frontend - npm ci - - - name: Build CLI - run: | - cd frontend/local-client-cli - npm run build - - - name: Publish to GitHub Packages - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - cd frontend/local-client-cli - # Update package.json to use scoped name for GitHub Packages - npm version --no-git-tag-version ${GITHUB_REF#refs/tags/} - npm publish diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..c9289a0f --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,44 @@ +name: Publish CLI + +on: + push: + tags: ["*"] + pull_request: + branches: ["main"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}-cli + +jobs: + publish-npm: + runs-on: self-hosted + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js environment + uses: actions/setup-node@v4.2.0 + with: + node-version: "22.x" + check-latest: true + registry-url: 'https://npm.pkg.github.com' + scope: '@schmelczer' + + - name: Install dependencies + run: | + cd frontend + npm ci + npm run build + + - name: Publish to GitHub Packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd frontend/local-client-cli + npm publish 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 -- 2.47.2 From 37f1f6ac019dd9da8d30b4d96bdce2b7983e864e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 21:35:21 +0100 Subject: [PATCH 11/15] Don't publish to NPM --- .github/workflows/publish-npm.yml | 44 ------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index c9289a0f..00000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Publish CLI - -on: - push: - tags: ["*"] - pull_request: - branches: ["main"] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}-cli - -jobs: - publish-npm: - runs-on: self-hosted - - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "22.x" - check-latest: true - registry-url: 'https://npm.pkg.github.com' - scope: '@schmelczer' - - - name: Install dependencies - run: | - cd frontend - npm ci - npm run build - - - name: Publish to GitHub Packages - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - cd frontend/local-client-cli - npm publish -- 2.47.2 From 450e62dd259c02e6a902a940e71781689434a903 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 21:45:17 +0100 Subject: [PATCH 12/15] Improve --- frontend/local-client-cli/Dockerfile | 4 +- frontend/local-client-cli/README.md | 320 ++++++++------------ frontend/local-client-cli/webpack.config.js | 48 +-- 3 files changed, 144 insertions(+), 228 deletions(-) diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 36a314d0..6b8e1d6c 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -15,9 +15,7 @@ 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 --chown=vaultlink:vaultlink /build/local-client-cli/dist/cli.js /app/cli.js +COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js WORKDIR /vault diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md index 42a036b2..0585bacc 100644 --- a/frontend/local-client-cli/README.md +++ b/frontend/local-client-cli/README.md @@ -1,57 +1,23 @@ # VaultLink Local CLI -A standalone command-line interface for syncing VaultLink vaults to your local filesystem. This CLI wraps the VaultLink sync client and provides file watching capabilities for real-time synchronization. - -## Features - -- Real-time bidirectional sync between local filesystem and VaultLink server -- File watching with automatic change detection -- Cross-platform support (Linux, macOS, Windows) -- Configuration via command-line arguments or JSON file -- Comprehensive error handling and logging -- Graceful shutdown on SIGINT/SIGTERM +Standalone CLI for syncing VaultLink vaults to local filesystem with real-time bidirectional sync and file watching. ## Installation -### Using Docker (Recommended) - -The easiest way to run VaultLink CLI is using Docker: +### Docker (Recommended) ```bash docker pull ghcr.io/schmelczer/vault-link-cli:latest -# Run with all options via command line docker run -v /path/to/vault:/vault \ ghcr.io/schmelczer/vault-link-cli:latest \ -l /vault \ - -r https://sync.example.com \ + -r wss://sync.example.com \ -t your-auth-token \ -v default - -# Or use a config file -docker run -v /path/to/vault:/vault \ - -v /path/to/config.json:/config.json \ - ghcr.io/schmelczer/vault-link-cli:latest \ - -l /vault \ - -c /config.json ``` -### Using Docker Compose - -```yaml -version: '3.8' - -services: - vaultlink-cli: - image: ghcr.io/schmelczer/vault-link-cli:latest - volumes: - - ./vault:/vault - - ./config.json:/config.json - command: -l /vault -c /config.json - restart: unless-stopped -``` - -### From npm Package +### npm ```bash npm install -g @schmelczer/local-client-cli @@ -69,220 +35,172 @@ node dist/cli.js --help ## Usage -### Basic Usage - ```bash vaultlink \ - --local-path ./my-vault \ - --remote-uri https://sync.example.com \ + --local-path ./vault \ + --remote-uri wss://sync.example.com \ --token your-auth-token \ --vault-name default ``` -### Using a Configuration File +## Options -Create a `config.json` file: +### Required -```json -{ - "remoteUri": "https://sync.example.com", - "token": "your-auth-token", - "vaultName": "default", - "syncConcurrency": 1, - "maxFileSizeMB": 10, - "ignorePatterns": [".git/**", "*.tmp"], - "webSocketRetryIntervalMs": 3500 -} -``` +| 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 | -Then run: +### Optional -```bash -vaultlink --local-path ./my-vault --config config.json -``` +| 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 | -### Command-Line Options +### Auto-Ignored Patterns -#### Required Arguments - -- `-l, --local-path ` - Local directory path to sync -- `-r, --remote-uri ` - Remote server URI (unless using --config) -- `-t, --token ` - Authentication token (unless using --config) -- `-v, --vault-name ` - Vault name (unless using --config) - -#### Optional Arguments - -- `-c, --config ` - Load configuration from JSON file -- `--sync-concurrency ` - Number of concurrent sync operations (default: 1) -- `--max-file-size-mb ` - Maximum file size in MB (default: 10) -- `--ignore-pattern ` - Pattern to ignore (can be used multiple times) -- `--websocket-retry-interval-ms ` - WebSocket retry interval in ms (default: 3500) -- `-h, --help` - Print help message -- `-V, --version` - Print version - -### Ignore Patterns - -Ignore patterns support glob syntax. The CLI automatically ignores: - -- `.vaultlink/**` - Internal sync data directory +- `.vaultlink/**` - Internal sync metadata - `.git/**` - Git repository files -You can add additional patterns: +### Examples +Basic usage: ```bash -vaultlink \ - --local-path ./my-vault \ - --remote-uri https://sync.example.com \ - --token mytoken \ - --vault-name default \ +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 -### Self-Hosting with Docker - -The CLI is designed to run as a long-lived container: +### Docker Run ```bash -# Create a vault directory -mkdir -p ./vault - -# Create config.json -cat > config.json < Date: Tue, 21 Oct 2025 21:56:28 +0100 Subject: [PATCH 13/15] Add log level handling --- frontend/local-client-cli/src/args.test.ts | 94 ++++++++++++++++++++++ frontend/local-client-cli/src/args.ts | 89 ++++++++++++-------- frontend/local-client-cli/src/cli.ts | 29 ++++--- 3 files changed, 167 insertions(+), 45 deletions(-) diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index 7d785361..206e39b7 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -1,6 +1,7 @@ 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([ @@ -134,3 +135,96 @@ test("parseArgs - throws on missing vault name", () => { ]); }, /--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 index 01e76fad..08ef2a6b 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import packageJson from "../package.json"; +import { LogLevel } from "sync-client"; export interface CliArgs { remoteUri: string; @@ -10,6 +11,7 @@ export interface CliArgs { maxFileSizeMB?: number; ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; + logLevel: LogLevel; } export function parseArgs(argv: string[]): CliArgs { @@ -21,20 +23,7 @@ export function parseArgs(argv: string[]): CliArgs { "VaultLink Local CLI - Sync your vault to the local filesystem" ) .version(packageJson.version) - .exitOverride((err) => { - // Let help and version exit normally - if ( - err.code === "commander.helpDisplayed" || - err.code === "commander.version" - ) { - process.exit(0); - } - throw err; - }) - .requiredOption( - "-l, --local-path ", - "Local directory path to sync" - ) + .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") @@ -57,6 +46,11 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] WebSocket retry interval in milliseconds", parseInt ) + .option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", + "INFO" + ) .addHelpText( "after", ` @@ -64,40 +58,65 @@ 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); - const options = program.opts<{ - localPath: string; - remoteUri?: string; - token?: string; - vaultName?: string; - syncConcurrency?: number; - maxFileSizeMb?: number; - ignorePattern?: string[]; - websocketRetryIntervalMs?: number; - }>(); + /* 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 (options.remoteUri === undefined) { + 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 (options.token === undefined) { + if (token === undefined) { throw new Error("required option '--token ' not specified"); } - if (options.vaultName === undefined) { + 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: options.localPath, - remoteUri: options.remoteUri ?? "", - token: options.token ?? "", - vaultName: options.vaultName ?? "", - syncConcurrency: options.syncConcurrency, - maxFileSizeMB: options.maxFileSizeMb, - ignorePatterns: options.ignorePattern, - webSocketRetryIntervalMs: options.websocketRetryIntervalMs + 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 index 8f0e367d..d453c551 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import { SyncClient, DEFAULT_SETTINGS, + LogLevel, type SyncSettings, type StoredDatabase } from "sync-client"; @@ -12,6 +13,13 @@ 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); @@ -34,7 +42,6 @@ async function main(): Promise { process.exit(1); } - // Print header with colors console.log( styleText("VaultLink Local CLI", "bold", "cyan") + colorize(` v${packageJson.version}`, "dim") @@ -112,9 +119,12 @@ async function main(): Promise { nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" }); - // Add colored log formatter + // Add colored log formatter with level filtering client.logger.addOnMessageListener((logLine) => { - console.log(formatLogLine(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"); @@ -122,10 +132,7 @@ async function main(): Promise { const fileWatcher = new FileWatcher(absolutePath, client); client.addWebSocketStatusChangeListener(() => { - const currentSettings = client.getSettings(); - if (currentSettings.isSyncEnabled) { - client.logger.info("WebSocket status changed"); - } + client.logger.info("WebSocket status changed"); }); client.addRemainingSyncOperationsListener((remaining) => { @@ -143,6 +150,7 @@ async function main(): Promise { "yellow" ) ); + fileWatcher.stop(); await client.waitAndStop(); console.log(colorize("Shutdown complete", "green")); @@ -179,9 +187,9 @@ async function main(): Promise { console.log(colorize("─".repeat(50), "dim")); console.log(""); - await new Promise(() => { - // Keep process alive until signal received - }); + // await new Promise(() => { + + // }); } catch (error) { console.error( colorize( @@ -189,6 +197,7 @@ async function main(): Promise { "red" ) ); + fileWatcher.stop(); await client.waitAndStop(); process.exit(1); -- 2.47.2 From a97cdf8876d68e38d9a3c978aefa36a85b32c827 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 21:57:14 +0100 Subject: [PATCH 14/15] Remove clutter --- frontend/package-lock.json | 5 +++- frontend/test-client/package.json | 45 +++++++++++++++---------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2c4f2552..6536a81e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1640,6 +1640,8 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -3027,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", @@ -4690,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/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" + } } -- 2.47.2 From ae8472c16b26209c86ddb78827f397ab6c9c7f85 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Oct 2025 22:44:46 +0100 Subject: [PATCH 15/15] Remove commented out code --- frontend/local-client-cli/src/cli.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index d453c551..5a3c6546 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -177,19 +177,11 @@ async function main(): Promise { } console.log(`${colorize("✓", "green")} Server connection successful`); + console.log(colorize("Press Ctrl+C to stop", "dim")); console.log(""); await client.start(); fileWatcher.start(); - - console.log(styleText("✓ Sync started", "bold", "green")); - console.log(colorize("Press Ctrl+C to stop", "dim")); - console.log(colorize("─".repeat(50), "dim")); - console.log(""); - - // await new Promise(() => { - - // }); } catch (error) { console.error( colorize( -- 2.47.2