Compare commits
3 commits
main
...
asch/deter
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e81343ab1 | |||
| 20e1c3f22d | |||
| a33e4bbcb9 |
31 changed files with 896 additions and 612 deletions
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directories: ["/frontend", "/docs"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directories: ["**"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directories: ["**"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
|
||||||
|
# Disable this for security reasons
|
||||||
|
# - package-ecosystem: "github-actions"
|
||||||
|
# directories: ["**"]
|
||||||
|
# schedule:
|
||||||
|
# interval: "daily"
|
||||||
36
.github/workflows/check.yml
vendored
Normal file
36
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
name: Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: "25.x"
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: "1.92.0"
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Lint & test
|
||||||
|
run: scripts/check.sh
|
||||||
58
.github/workflows/deploy-docs.yml
vendored
Normal file
58
.github/workflows/deploy-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: Deploy Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- ".github/workflows/deploy-docs.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: "25.x"
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
|
run: scripts/build-docs.sh
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs/.vitepress/dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
needs: build
|
||||||
|
runs-on: self-hosted
|
||||||
|
name: Deploy
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
72
.github/workflows/e2e.yml
vendored
Normal file
72
.github/workflows/e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
name: E2E tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: e2e-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: "25.x"
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: "1.92.0"
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Setup rust
|
||||||
|
run: |
|
||||||
|
which sqlx || cargo install sqlx-cli
|
||||||
|
cd sync-server
|
||||||
|
sqlx database create --database-url sqlite://db.sqlite3
|
||||||
|
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||||
|
|
||||||
|
- name: E2E tests
|
||||||
|
run: |
|
||||||
|
cd sync-server
|
||||||
|
cargo run config-e2e.yml --color never &
|
||||||
|
SERVER_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
scripts/e2e.sh 8
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
wait $SERVER_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
|
|
||||||
|
- name: Upload e2e logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-logs
|
||||||
|
path: logs/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: scripts/clean-up.sh
|
||||||
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
name: Publish CLI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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}
|
||||||
59
.github/workflows/publish-plugin.yml
vendored
Normal file
59
.github/workflows/publish-plugin.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
name: Publish Obsidian plugin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-plugin:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: "25.x"
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Setup Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: "1.92.0"
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
run: |
|
||||||
|
apt update
|
||||||
|
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
|
- name: Build Linux and Windows binaries
|
||||||
|
run: ./scripts/build-sync-server-binaries.sh
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
mkdir -p release
|
||||||
|
cp frontend/obsidian-plugin/dist/* release/
|
||||||
|
cp sync-server/artifacts/sync-server-* release/
|
||||||
|
cd release
|
||||||
|
|
||||||
|
gh release create "$tag" \
|
||||||
|
--title="$tag" \
|
||||||
|
--draft \
|
||||||
|
*
|
||||||
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
name: Publish server Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use docker.io for Docker Hub if empty
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# github.repository as <account>/<repo>
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-docker:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
# This is used to complete the identity challenge
|
||||||
|
# with sigstore/fulcio.
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Install the cosign tool
|
||||||
|
# https://github.com/sigstore/cosign-installer
|
||||||
|
- name: Install cosign
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||||
|
with:
|
||||||
|
cosign-release: "v2.2.4"
|
||||||
|
|
||||||
|
# Set up BuildKit Docker container builder to be able to build
|
||||||
|
# multi-platform images and export cache
|
||||||
|
# https://github.com/docker/setup-buildx-action
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||||
|
|
||||||
|
# Login against a Docker registry
|
||||||
|
# https://github.com/docker/login-action
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Extract metadata (tags, labels) for Docker
|
||||||
|
# https://github.com/docker/metadata-action
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
# Build and push Docker image with Buildx
|
||||||
|
# https://github.com/docker/build-push-action
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
|
with:
|
||||||
|
context: sync-server
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.ref_type == 'tag' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Sign the resulting Docker image digest.
|
||||||
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
|
# repository is public to avoid leaking data. If you would like to publish
|
||||||
|
# transparency data even for private images, pass --force to cosign below.
|
||||||
|
# https://github.com/sigstore/cosign
|
||||||
|
- name: Sign the published Docker image
|
||||||
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
|
env:
|
||||||
|
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||||
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||||
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
|
# against the sigstore community Fulcio instance.
|
||||||
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:25-slim AS builder
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ COPY . .
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:25-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
||||||
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
||||||
|
|
|
||||||
|
|
@ -47,25 +47,24 @@ vaultlink \
|
||||||
|
|
||||||
### Required
|
### Required
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
| ------------------------- | --------------------------------------------- |
|
|--------|-------------|
|
||||||
| `-l, --local-path <path>` | Local directory to sync |
|
| `-l, --local-path <path>` | Local directory to sync |
|
||||||
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||||
| `-t, --token <token>` | Authentication token |
|
| `-t, --token <token>` | Authentication token |
|
||||||
| `-v, --vault-name <name>` | Vault name on server |
|
| `-v, --vault-name <name>` | Vault name on server |
|
||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------------------ | ------- | ----------------------------------------------- |
|
|--------|---------|-------------|
|
||||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
||||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||||
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
|
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||||
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
|
| `-h, --help` | - | Show help |
|
||||||
| `-h, --help` | - | Show help |
|
| `-V, --version` | - | Show version |
|
||||||
| `-V, --version` | - | Show version |
|
|
||||||
|
|
||||||
### Auto-Ignored Patterns
|
### Auto-Ignored Patterns
|
||||||
|
|
||||||
|
|
@ -75,32 +74,22 @@ vaultlink \
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Basic usage:
|
Basic usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
||||||
```
|
```
|
||||||
|
|
||||||
With ignore patterns:
|
With ignore patterns:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
--ignore-pattern "**/*.tmp" \
|
--ignore-pattern "*.tmp" \
|
||||||
--ignore-pattern ".DS_Store" \
|
--ignore-pattern ".DS_Store" \
|
||||||
--ignore-pattern "node_modules/**"
|
--ignore-pattern "node_modules/**"
|
||||||
```
|
```
|
||||||
|
|
||||||
With debug logging and quiet startup:
|
With debug logging:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
--log-level DEBUG --quiet
|
--log-level DEBUG
|
||||||
```
|
|
||||||
|
|
||||||
Force LF line endings (useful for cross-platform vaults):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
|
||||||
--line-endings lf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
@ -187,7 +176,6 @@ services:
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
# or from the parent folder, run
|
# or from the parent folder, run
|
||||||
|
|
@ -195,13 +183,11 @@ docker build -f local-client-cli/Dockerfile .
|
||||||
```
|
```
|
||||||
|
|
||||||
Test:
|
Test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker build:
|
Docker build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,18 @@
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test 'src/**/*.test.ts'"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"watcher": "^2.3.1",
|
"watcher": "^2.3.1"
|
||||||
"@types/node": "^25.0.2",
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.8.1",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"ts-loader": "^9.5.4",
|
"ts-loader": "^9.5.2",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.8.3",
|
||||||
"webpack": "^5.103.0",
|
"webpack": "^5.99.9",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack-cli": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,13 @@ test("parseArgs - parse with optional arguments", () => {
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
|
"--sync-concurrency",
|
||||||
|
"5",
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.syncConcurrency, 5);
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -150,6 +153,25 @@ test("parseArgs - default log level is INFO", () => {
|
||||||
assert.equal(args.logLevel, LogLevel.INFO);
|
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", () => {
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
|
|
@ -169,32 +191,28 @@ test("parseArgs - parse ERROR log level", () => {
|
||||||
assert.equal(args.logLevel, LogLevel.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"
|
||||||
|
]);
|
||||||
|
|
||||||
test("parseArgs - reads required options from environment variables", () => {
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
|
|
||||||
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";
|
|
||||||
process.env.VAULTLINK_TOKEN = "env-token";
|
|
||||||
process.env.VAULTLINK_VAULT_NAME = "env-vault";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const args = parseArgs(["node", "cli.js"]);
|
|
||||||
assert.equal(args.localPath, "/env/path");
|
|
||||||
assert.equal(args.remoteUri, "https://env.example.com");
|
|
||||||
assert.equal(args.token, "env-token");
|
|
||||||
assert.equal(args.vaultName, "env-vault");
|
|
||||||
} finally {
|
|
||||||
delete process.env.VAULTLINK_LOCAL_PATH;
|
|
||||||
delete process.env.VAULTLINK_REMOTE_URI;
|
|
||||||
delete process.env.VAULTLINK_TOKEN;
|
|
||||||
delete process.env.VAULTLINK_VAULT_NAME;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - CLI arguments take precedence over environment variables", () => {
|
test("parseArgs - throws on invalid log level", () => {
|
||||||
process.env.VAULTLINK_TOKEN = "env-token";
|
assert.throws(() => {
|
||||||
|
parseArgs([
|
||||||
try {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
|
|
@ -202,12 +220,11 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"cli-token",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default",
|
||||||
|
"--log-level",
|
||||||
|
"INVALID"
|
||||||
]);
|
]);
|
||||||
assert.equal(args.token, "cli-token");
|
}, /Invalid log level/);
|
||||||
} finally {
|
|
||||||
delete process.env.VAULTLINK_TOKEN;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,19 @@
|
||||||
import { Command, Option } from "commander";
|
import { Command } from "commander";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
|
export interface CliArgs {
|
||||||
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
|
|
||||||
|
|
||||||
interface CliArgs {
|
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
|
syncConcurrency?: number;
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
quiet: boolean;
|
|
||||||
lineEndings: LineEndingMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
|
|
||||||
|
|
||||||
const REQUIRED_OPTIONS = {
|
|
||||||
localPath: {
|
|
||||||
flags: "-l, --local-path <path>",
|
|
||||||
env: "VAULTLINK_LOCAL_PATH"
|
|
||||||
},
|
|
||||||
remoteUri: {
|
|
||||||
flags: "-r, --remote-uri <uri>",
|
|
||||||
env: "VAULTLINK_REMOTE_URI"
|
|
||||||
},
|
|
||||||
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
|
|
||||||
vaultName: {
|
|
||||||
flags: "-v, --vault-name <name>",
|
|
||||||
env: "VAULTLINK_VAULT_NAME"
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function requireOption<T>(
|
|
||||||
value: T | undefined,
|
|
||||||
name: keyof typeof REQUIRED_OPTIONS
|
|
||||||
): T {
|
|
||||||
if (value === undefined) {
|
|
||||||
const { flags, env } = REQUIRED_OPTIONS[name];
|
|
||||||
throw new Error(
|
|
||||||
`required option '${flags}' not specified (or set ${env})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
|
|
@ -60,85 +25,41 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.addOption(
|
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||||
new Option(
|
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||||
REQUIRED_OPTIONS.localPath.flags,
|
.option("-t, --token <token>", "Authentication token")
|
||||||
"Local directory path to sync"
|
.option("-v, --vault-name <name>", "Vault name")
|
||||||
).env(REQUIRED_OPTIONS.localPath.env)
|
.option(
|
||||||
|
"--sync-concurrency <number>",
|
||||||
|
"[OPTIONAL] Number of concurrent sync operations",
|
||||||
|
parseInt
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(
|
"--max-file-size-mb <number>",
|
||||||
REQUIRED_OPTIONS.remoteUri.flags,
|
"[OPTIONAL] Maximum file size in MB",
|
||||||
"Remote server URI"
|
parseInt
|
||||||
).env(REQUIRED_OPTIONS.remoteUri.env)
|
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(
|
"--ignore-pattern <pattern...>",
|
||||||
REQUIRED_OPTIONS.token.flags,
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
"Authentication token"
|
|
||||||
).env(REQUIRED_OPTIONS.token.env)
|
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
|
"--websocket-retry-interval-ms <number>",
|
||||||
REQUIRED_OPTIONS.vaultName.env
|
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||||
)
|
parseInt
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(
|
"--log-level <level>",
|
||||||
"--max-file-size-mb <number>",
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||||
"[OPTIONAL] Maximum file size in MB"
|
"INFO"
|
||||||
)
|
|
||||||
.argParser(parseInt)
|
|
||||||
.env("VAULTLINK_MAX_FILE_SIZE_MB")
|
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(
|
"--health <path>",
|
||||||
"--ignore-pattern <pattern...>",
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
|
||||||
).env("VAULTLINK_IGNORE_PATTERNS")
|
|
||||||
)
|
)
|
||||||
.addOption(
|
.option(
|
||||||
new Option(
|
"--enable-telemetry",
|
||||||
"--websocket-retry-interval-ms <number>",
|
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds"
|
|
||||||
)
|
|
||||||
.argParser(parseInt)
|
|
||||||
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option(
|
|
||||||
"--log-level <level>",
|
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
|
|
||||||
)
|
|
||||||
.default("INFO")
|
|
||||||
.env("VAULTLINK_LOG_LEVEL")
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option(
|
|
||||||
"--health <path>",
|
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
|
||||||
).env("VAULTLINK_HEALTH")
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option(
|
|
||||||
"--enable-telemetry",
|
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
|
||||||
).env("VAULTLINK_ENABLE_TELEMETRY")
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option(
|
|
||||||
"-q, --quiet",
|
|
||||||
"[OPTIONAL] Suppress startup banner for non-interactive use"
|
|
||||||
).env("VAULTLINK_QUIET")
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option(
|
|
||||||
"--line-endings <mode>",
|
|
||||||
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
|
||||||
)
|
|
||||||
.default("auto")
|
|
||||||
.choices([...LINE_ENDING_MODES])
|
|
||||||
.env("VAULTLINK_LINE_ENDINGS")
|
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
|
|
@ -146,13 +67,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
Examples:
|
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
|
||||||
$ 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"
|
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||||
$ 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 \\
|
||||||
--log-level DEBUG --quiet
|
--log-level DEBUG
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
All options can be configured via VAULTLINK_ prefixed environment variables.
|
|
||||||
CLI arguments take precedence over environment variables.
|
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -164,6 +81,7 @@ Environment variables:
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName 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 maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
|
|
@ -172,23 +90,22 @@ Environment variables:
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||||
const quiet = (opts.quiet as boolean | undefined) ?? false;
|
|
||||||
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
const requiredLocalPath = requireOption(localPath, "localPath");
|
if (localPath === undefined) {
|
||||||
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
|
||||||
const requiredToken = requireOption(token, "token");
|
|
||||||
const requiredVaultName = requireOption(vaultName, "vaultName");
|
|
||||||
|
|
||||||
// Validate remote URI protocol
|
|
||||||
if (
|
|
||||||
!VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix))
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
|
"required option '-l, --local-path <path>' not specified"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (remoteUri === undefined) {
|
||||||
|
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||||
|
}
|
||||||
|
if (token === undefined) {
|
||||||
|
throw new Error("required option '--token <token>' not specified");
|
||||||
|
}
|
||||||
|
if (vaultName === undefined) {
|
||||||
|
throw new Error("required option '--vault-name <name>' not specified");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
const logLevelUpper = logLevelStr.toUpperCase();
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
|
|
@ -203,27 +120,17 @@ Environment variables:
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
const isLineEndingMode = (value: string): value is LineEndingMode =>
|
|
||||||
(LINE_ENDING_MODES as readonly string[]).includes(value);
|
|
||||||
if (!isLineEndingMode(lineEndingsStr)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const lineEndings = lineEndingsStr;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localPath: requiredLocalPath,
|
localPath,
|
||||||
remoteUri: requiredRemoteUri,
|
remoteUri,
|
||||||
token: requiredToken,
|
token,
|
||||||
vaultName: requiredVaultName,
|
vaultName,
|
||||||
|
syncConcurrency,
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry,
|
enableTelemetry
|
||||||
quiet,
|
|
||||||
lineEndings
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,27 +5,24 @@ import type { NetworkConnectionStatus } from "sync-client";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
Logger,
|
|
||||||
LogLevel,
|
LogLevel,
|
||||||
LogLine,
|
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs, type LineEndingMode } from "./args";
|
import { parseArgs } from "./args";
|
||||||
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
import { FileWatcher } from "./file-watcher";
|
import { FileWatcher } from "./file-watcher";
|
||||||
import { formatLogLine } from "./logger-formatter";
|
import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
function writeHealthStatus(
|
function writeHealthStatus(
|
||||||
logger: Logger,
|
|
||||||
filePath: string,
|
filePath: string,
|
||||||
connectionStatus: NetworkConnectionStatus
|
connectionStatus: NetworkConnectionStatus
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
console.error(
|
||||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -38,41 +35,12 @@ const LOG_LEVEL_ORDER = {
|
||||||
[LogLevel.ERROR]: 3
|
[LogLevel.ERROR]: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
|
|
||||||
return (logLine: LogLine): void => {
|
|
||||||
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(formatLogLine(logLine));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
||||||
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
function resolveLineEndings(mode: LineEndingMode): string {
|
|
||||||
switch (mode) {
|
|
||||||
case "lf":
|
|
||||||
return "\n";
|
|
||||||
case "crlf":
|
|
||||||
return "\r\n";
|
|
||||||
case "auto":
|
|
||||||
return process.platform === "win32" ? "\r\n" : "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
const absolutePath = path.resolve(args.localPath);
|
const absolutePath = path.resolve(args.localPath);
|
||||||
|
|
||||||
const logHandler = createLogHandler(args.logLevel);
|
|
||||||
// Boot-time messages are emitted directly through logHandler before the
|
|
||||||
// SyncClient (and its Logger) exist; afterwards every log line flows
|
|
||||||
// through client.logger.
|
|
||||||
const emitBoot = (level: LogLevel, message: string): void => {
|
|
||||||
logHandler(new LogLine(level, message));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!fsSync.existsSync(absolutePath)) {
|
if (!fsSync.existsSync(absolutePath)) {
|
||||||
fsSync.mkdirSync(absolutePath, { recursive: true });
|
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
@ -80,31 +48,38 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
|
console.error(
|
||||||
|
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitBoot(
|
console.error(
|
||||||
LogLevel.ERROR,
|
colorize(
|
||||||
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.quiet) {
|
console.log(
|
||||||
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
|
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||||
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
|
colorize(` v${packageJson.version}`, "dim")
|
||||||
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
|
);
|
||||||
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
|
console.log(colorize("=".repeat(50), "dim"));
|
||||||
if (args.lineEndings !== "auto") {
|
console.log(
|
||||||
emitBoot(
|
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||||
LogLevel.INFO,
|
);
|
||||||
`Line endings: ${args.lineEndings.toUpperCase()}`
|
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_DIR);
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
@ -113,7 +88,8 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
const ignorePatterns = [
|
const ignorePatterns = [
|
||||||
...(args.ignorePatterns ?? []),
|
...(args.ignorePatterns ?? []),
|
||||||
`${VAULTLINK_DIR}/**`
|
".vaultlink/**",
|
||||||
|
".git/**"
|
||||||
];
|
];
|
||||||
|
|
||||||
const settings: SyncSettings = {
|
const settings: SyncSettings = {
|
||||||
|
|
@ -121,6 +97,8 @@ async function main(): Promise<void> {
|
||||||
remoteUri: args.remoteUri,
|
remoteUri: args.remoteUri,
|
||||||
token: args.token,
|
token: args.token,
|
||||||
vaultName: args.vaultName,
|
vaultName: args.vaultName,
|
||||||
|
syncConcurrency:
|
||||||
|
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
ignorePatterns,
|
ignorePatterns,
|
||||||
webSocketRetryIntervalMs:
|
webSocketRetryIntervalMs:
|
||||||
|
|
@ -141,9 +119,11 @@ async function main(): Promise<void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
} catch {
|
} catch {
|
||||||
emitBoot(
|
console.error(
|
||||||
LogLevel.WARNING,
|
colorize(
|
||||||
`Cannot read data file at ${dataFile}`
|
`Cannot read data file at ${dataFile}`,
|
||||||
|
"yellow"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,27 +133,23 @@ async function main(): Promise<void> {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
save: async ({ database: persistedDatabase }) => {
|
save: async ({ database: persistedDatabase }) => {
|
||||||
|
// settings can't be updated when running with this CLI
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
dataFile,
|
dataFile,
|
||||||
JSON.stringify(persistedDatabase, null, 2)
|
JSON.stringify(persistedDatabase, null, 2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nativeLineEndings: resolveLineEndings(args.lineEndings)
|
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.health !== undefined) {
|
if (args.health !== undefined) {
|
||||||
const healthFile = args.health;
|
const healthFile = args.health;
|
||||||
const writeHealth = (): void => {
|
const healthInterval = setInterval(() => {
|
||||||
void client.checkConnection().then((status) => {
|
void client.checkConnection().then((status) => {
|
||||||
writeHealthStatus(client.logger, healthFile, status);
|
writeHealthStatus(healthFile, status);
|
||||||
});
|
});
|
||||||
};
|
}, HEALTH_CHECK_INTERVAL_MS);
|
||||||
writeHealth();
|
|
||||||
const healthInterval = setInterval(
|
|
||||||
writeHealth,
|
|
||||||
HEALTH_CHECK_INTERVAL_MS
|
|
||||||
);
|
|
||||||
const clearHealthInterval = (): void => {
|
const clearHealthInterval = (): void => {
|
||||||
clearInterval(healthInterval);
|
clearInterval(healthInterval);
|
||||||
};
|
};
|
||||||
|
|
@ -182,10 +158,17 @@ async function main(): Promise<void> {
|
||||||
process.on("exit", clearHealthInterval);
|
process.on("exit", clearHealthInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.logger.onLogEmitted.add(logHandler);
|
// Add colored log formatter with level filtering
|
||||||
|
client.logger.onLogEmitted.add((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");
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
|
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||||
|
|
||||||
client.onWebSocketStatusChanged.add(() => {
|
client.onWebSocketStatusChanged.add(() => {
|
||||||
const isConnected = client.isWebSocketConnected;
|
const isConnected = client.isWebSocketConnected;
|
||||||
|
|
@ -194,54 +177,26 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
let syncBatchSize = 0;
|
|
||||||
let totalSyncOps = 0;
|
|
||||||
let lastProgressLogTime = 0;
|
|
||||||
|
|
||||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||||
if (remaining > syncBatchSize) {
|
|
||||||
syncBatchSize = remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
if (syncBatchSize > 0) {
|
client.logger.info("All sync operations completed");
|
||||||
totalSyncOps += syncBatchSize;
|
|
||||||
client.logger.info(
|
|
||||||
`Sync batch complete (${syncBatchSize} operations)`
|
|
||||||
);
|
|
||||||
syncBatchSize = 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const now = Date.now();
|
client.logger.info(`${remaining} sync operations remaining`);
|
||||||
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
|
|
||||||
client.logger.info(
|
|
||||||
`Syncing: ${remaining} operations remaining`
|
|
||||||
);
|
|
||||||
lastProgressLogTime = now;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let isShuttingDown = false;
|
|
||||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
if (isShuttingDown) {
|
console.log(
|
||||||
return;
|
colorize(
|
||||||
}
|
`\n${signal} received. Shutting down gracefully...`,
|
||||||
isShuttingDown = true;
|
"yellow"
|
||||||
|
)
|
||||||
client.logger.info(`${signal} received, shutting down gracefully`);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
await client.waitUntilFinished();
|
await client.waitUntilFinished();
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
|
console.log(colorize("Shutdown complete", "green"));
|
||||||
if (totalSyncOps > 0) {
|
|
||||||
client.logger.info(
|
|
||||||
`Shutdown complete (${totalSyncOps} operations synced)`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
client.logger.info("Shutdown complete");
|
|
||||||
}
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -255,21 +210,27 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const connectionStatus = await client.checkConnection();
|
const connectionStatus = await client.checkConnection();
|
||||||
if (!connectionStatus.isSuccessful) {
|
if (!connectionStatus.isSuccessful) {
|
||||||
client.logger.error(
|
console.error(
|
||||||
`Cannot connect to server: ${connectionStatus.serverMessage}`
|
colorize(
|
||||||
|
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.quiet) {
|
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||||
client.logger.info("Server connection successful");
|
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||||
}
|
console.log("");
|
||||||
|
|
||||||
await client.start();
|
await client.start();
|
||||||
fileWatcher.start();
|
fileWatcher.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.logger.error(
|
console.error(
|
||||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
colorize(
|
||||||
|
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
|
|
@ -279,9 +240,11 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
console.error(
|
||||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
colorize(
|
||||||
|
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
"red"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
import Watcher from "watcher";
|
import Watcher from "watcher";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { SyncClient, RelativePath } from "sync-client";
|
import type { SyncClient, RelativePath } from "sync-client";
|
||||||
import { toUnixPath, matchesGlob } from "./path-utils";
|
|
||||||
|
|
||||||
export class FileWatcher {
|
export class FileWatcher {
|
||||||
private watcher: Watcher | undefined;
|
private watcher: Watcher | undefined;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
private readonly ignorePatterns: string[];
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient
|
||||||
ignorePatterns: string[] = []
|
) {}
|
||||||
) {
|
|
||||||
this.ignorePatterns = ignorePatterns;
|
|
||||||
}
|
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
|
|
@ -27,8 +22,7 @@ export class FileWatcher {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
renameDetection: true,
|
renameDetection: true,
|
||||||
renameTimeout: 125,
|
renameTimeout: 125,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true
|
||||||
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watcher.on("add", (filePath: string) => {
|
this.watcher.on("add", (filePath: string) => {
|
||||||
|
|
@ -62,32 +56,66 @@ export class FileWatcher {
|
||||||
this.client.logger.info("File watcher stopped");
|
this.client.logger.info("File watcher stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldIgnore(filePath: string): boolean {
|
|
||||||
const rel = toUnixPath(path.relative(this.basePath, filePath));
|
|
||||||
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCreate(relativePath: RelativePath): void {
|
private handleCreate(relativePath: RelativePath): void {
|
||||||
this.client.syncLocallyCreatedFile(relativePath);
|
this.client
|
||||||
|
.syncLocallyCreatedFile(relativePath)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.client.logger.error(
|
||||||
|
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChange(relativePath: RelativePath): void {
|
private handleChange(relativePath: RelativePath): void {
|
||||||
this.client.syncLocallyUpdatedFile({ relativePath });
|
this.client
|
||||||
|
.syncLocallyUpdatedFile({ relativePath })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.client.logger.error(
|
||||||
|
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDelete(relativePath: RelativePath): void {
|
private handleDelete(relativePath: RelativePath): void {
|
||||||
this.client.syncLocallyDeletedFile(relativePath);
|
this.client
|
||||||
|
.syncLocallyDeletedFile(relativePath)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.client.logger.error(
|
||||||
|
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
|
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||||
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
|
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
|
||||||
this.client.syncLocallyUpdatedFile({
|
this.client
|
||||||
oldPath,
|
.syncLocallyUpdatedFile({
|
||||||
relativePath: newPath
|
oldPath,
|
||||||
});
|
relativePath: newPath
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.client.logger.error(
|
||||||
|
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRelativePath(absolutePath: string): RelativePath {
|
private toRelativePath(absolutePath: string): RelativePath {
|
||||||
return toUnixPath(path.relative(this.basePath, absolutePath));
|
const relative = path.relative(this.basePath, absolutePath);
|
||||||
|
return this.toUnixPath(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a native platform path to forward slashes
|
||||||
|
*/
|
||||||
|
private toUnixPath(nativePath: string): string {
|
||||||
|
if (path.sep === "\\") {
|
||||||
|
return nativePath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
return nativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatError(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Healthcheck script for Docker container
|
* Healthcheck script for Docker container
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { test } from "node:test";
|
|
||||||
import * as assert from "node:assert/strict";
|
|
||||||
import { formatLogLine } from "./logger-formatter";
|
|
||||||
import { LogLevel } from "sync-client";
|
|
||||||
|
|
||||||
test("formatLogLine - includes level and message", () => {
|
|
||||||
const logLine = {
|
|
||||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
|
||||||
level: LogLevel.INFO,
|
|
||||||
message: "Test message"
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatLogLine(logLine);
|
|
||||||
assert.ok(result.includes("INFO"));
|
|
||||||
assert.ok(result.includes("Test message"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatLogLine - ERROR level messages contain bold escape", () => {
|
|
||||||
const logLine = {
|
|
||||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
|
||||||
level: LogLevel.ERROR,
|
|
||||||
message: "Error occurred"
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatLogLine(logLine);
|
|
||||||
assert.ok(result.includes("\x1b[1m"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatLogLine - highlights file paths in quotes", () => {
|
|
||||||
const logLine = {
|
|
||||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
|
||||||
level: LogLevel.INFO,
|
|
||||||
message: 'Syncing "notes/test.md"'
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatLogLine(logLine);
|
|
||||||
assert.ok(result.includes("\x1b[35m"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
|
|
||||||
const logLine = {
|
|
||||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
|
||||||
level: LogLevel.INFO,
|
|
||||||
message: "Listed 42 files from v1.2.3"
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatLogLine(logLine);
|
|
||||||
assert.ok(result.includes("\x1b[36m42\x1b[0m"));
|
|
||||||
assert.ok(!result.includes("\x1b[36m1\x1b[0m."));
|
|
||||||
});
|
|
||||||
|
|
@ -1,21 +1,36 @@
|
||||||
import { LogLevel, type LogLine } from "sync-client";
|
import { LogLevel, type LogLine } from "sync-client";
|
||||||
|
|
||||||
const colors = {
|
// ANSI color codes
|
||||||
|
export const colors = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
bold: "\x1b[1m",
|
bold: "\x1b[1m",
|
||||||
|
dim: "\x1b[2m",
|
||||||
|
|
||||||
|
// Foreground colors
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
|
blue: "\x1b[34m",
|
||||||
magenta: "\x1b[35m",
|
magenta: "\x1b[35m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
gray: "\x1b[90m"
|
gray: "\x1b[90m"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function colorize(text: string, color: keyof typeof colors): string {
|
export function colorize(text: string, color: keyof typeof colors): string {
|
||||||
return `${colors[color]}${text}${colors.reset}`;
|
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 {
|
function formatTimestamp(date: Date): string {
|
||||||
const [time] = date.toTimeString().split(" ");
|
const [time] = date.toTimeString().split(" ");
|
||||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import type { Dirent } from "fs";
|
import type { Dirent } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import type {
|
import type {
|
||||||
FileSystemOperations,
|
FileSystemOperations,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { toUnixPath } from "./path-utils";
|
|
||||||
|
|
||||||
// VaultLink's per-vault metadata directory. Holds the persisted sync database
|
|
||||||
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
|
|
||||||
// ignore pattern keeps everything in here invisible to the file watcher.
|
|
||||||
export const VAULTLINK_DIR = ".vaultlink";
|
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) { }
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
directory: RelativePath | undefined
|
directory: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(directory ?? "", files);
|
await this.walkDirectory(
|
||||||
|
directory !== undefined ? this.toNativePath(directory) : "",
|
||||||
|
files
|
||||||
|
);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -40,12 +39,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
content: Uint8Array
|
content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await this.atomicWrite(fullPath, content);
|
await fs.writeFile(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
|
@ -57,12 +59,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
const result = updater({ text: currentContent, cursors: [] });
|
||||||
await this.atomicWrite(fullPath, result.text, "utf-8");
|
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||||
return result.text;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -72,7 +77,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
return stats.size;
|
return stats.size;
|
||||||
|
|
@ -84,7 +92,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -94,7 +105,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -105,7 +119,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(relativePath)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -119,8 +136,14 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(this.basePath, oldPath);
|
const oldFullPath = path.join(
|
||||||
const newFullPath = path.join(this.basePath, newPath);
|
this.basePath,
|
||||||
|
this.toNativePath(oldPath)
|
||||||
|
);
|
||||||
|
const newFullPath = path.join(
|
||||||
|
this.basePath,
|
||||||
|
this.toNativePath(newPath)
|
||||||
|
);
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -133,44 +156,6 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async atomicWrite(
|
|
||||||
fullPath: string,
|
|
||||||
content: Uint8Array | string,
|
|
||||||
encoding?: BufferEncoding
|
|
||||||
): Promise<void> {
|
|
||||||
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
|
|
||||||
await fs.mkdir(tmpDir, { recursive: true });
|
|
||||||
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
|
|
||||||
try {
|
|
||||||
await fs.writeFile(tmpPath, content, encoding);
|
|
||||||
const fd = await fs.open(tmpPath, "r");
|
|
||||||
try {
|
|
||||||
await fd.datasync();
|
|
||||||
} finally {
|
|
||||||
await fd.close();
|
|
||||||
}
|
|
||||||
await fs.rename(tmpPath, fullPath);
|
|
||||||
await this.syncDirectory(path.dirname(fullPath));
|
|
||||||
} catch (error) {
|
|
||||||
await fs.unlink(tmpPath).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the rename durable by fsync'ing the destination's parent directory.
|
|
||||||
// Skipped on Windows: fsync on a directory handle isn't supported there
|
|
||||||
private async syncDirectory(dir: string): Promise<void> {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fd = await fs.open(dir, "r");
|
|
||||||
try {
|
|
||||||
await fd.sync();
|
|
||||||
} finally {
|
|
||||||
await fd.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
|
|
@ -194,8 +179,28 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// Always return forward slashes
|
||||||
files.push(toUnixPath(entryRelativePath));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { test } from "node:test";
|
|
||||||
import * as assert from "node:assert/strict";
|
|
||||||
import { matchesGlob, toUnixPath } from "./path-utils";
|
|
||||||
|
|
||||||
test("matchesGlob - exact match", () => {
|
|
||||||
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
|
|
||||||
assert.equal(matchesGlob("other", ".DS_Store"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - dir/** matches directory and contents", () => {
|
|
||||||
assert.equal(matchesGlob(".git", ".git/**"), true);
|
|
||||||
assert.equal(matchesGlob(".git/config", ".git/**"), true);
|
|
||||||
assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true);
|
|
||||||
assert.equal(matchesGlob(".gitignore", ".git/**"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - * matches within a single segment", () => {
|
|
||||||
assert.equal(matchesGlob("foo.tmp", "*.tmp"), true);
|
|
||||||
assert.equal(matchesGlob("bar.tmp", "*.tmp"), true);
|
|
||||||
assert.equal(matchesGlob("foo.md", "*.tmp"), false);
|
|
||||||
assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - **/*.ext matches at any depth", () => {
|
|
||||||
assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true);
|
|
||||||
assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true);
|
|
||||||
assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true);
|
|
||||||
assert.equal(matchesGlob("foo.md", "**/*.tmp"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - ? matches single character", () => {
|
|
||||||
assert.equal(matchesGlob("a.md", "?.md"), true);
|
|
||||||
assert.equal(matchesGlob("ab.md", "?.md"), false);
|
|
||||||
assert.equal(matchesGlob(".md", "?.md"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - dots are literal", () => {
|
|
||||||
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
|
|
||||||
assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - node_modules/** matches directory tree", () => {
|
|
||||||
assert.equal(matchesGlob("node_modules", "node_modules/**"), true);
|
|
||||||
assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true);
|
|
||||||
assert.equal(
|
|
||||||
matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesGlob - **/ prefix matches zero or more segments", () => {
|
|
||||||
assert.equal(matchesGlob("test.log", "**/test.log"), true);
|
|
||||||
assert.equal(matchesGlob("dir/test.log", "**/test.log"), true);
|
|
||||||
assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toUnixPath - forward slashes unchanged", () => {
|
|
||||||
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
|
|
||||||
});
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
// Convert a native platform path to forward slashes (no-op on non-Windows)
|
|
||||||
export function toUnixPath(nativePath: string): string {
|
|
||||||
return nativePath.split(path.sep).join(path.posix.sep);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match a file path against a glob pattern.
|
|
||||||
//
|
|
||||||
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
|
|
||||||
// the directory `dir` itself, not only its descendants. The watcher feeds us
|
|
||||||
// a directory's relative path (e.g. ".git") at the same time it's about to
|
|
||||||
// recurse into it, and the natural way for users to write the ignore pattern
|
|
||||||
// is `.git/**` — under stdlib semantics that pattern would let the directory
|
|
||||||
// through and only block its children, defeating the prune.
|
|
||||||
export function matchesGlob(filePath: string, pattern: string): boolean {
|
|
||||||
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return path.matchesGlob(filePath, pattern);
|
|
||||||
}
|
|
||||||
|
|
@ -18,5 +18,7 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"exclude": ["dist"]
|
"exclude": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,32 @@ const path = require("path");
|
||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
cli: "./src/cli.ts",
|
cli: "./src/cli.ts",
|
||||||
healthcheck: "./src/healthcheck.ts"
|
healthcheck: "./src/healthcheck.ts"
|
||||||
},
|
},
|
||||||
target: "node",
|
target: "node",
|
||||||
mode: "production",
|
mode: "production",
|
||||||
optimization: {
|
optimization: {
|
||||||
minimize: false
|
minimize: false
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.ts$/,
|
test: /\.ts$/,
|
||||||
use: "ts-loader"
|
use: "ts-loader"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [".ts", ".js"]
|
extensions: [".ts", ".js"]
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
globalObject: "this",
|
globalObject: "this",
|
||||||
filename: "[name].js",
|
filename: "[name].js",
|
||||||
path: path.resolve(__dirname, "dist")
|
path: path.resolve(__dirname, "dist")
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
|
||||||
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
||||||
|
|
||||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||||
|
|
||||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
- Adds a command "Open Sample Modal" which opens a Modal.
|
||||||
- Adds a plugin setting tab to the settings page.
|
- Adds a plugin setting tab to the settings page.
|
||||||
|
|
@ -58,6 +57,31 @@ Quick starting guide for new plugin devs:
|
||||||
|
|
||||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||||
|
|
||||||
|
|
||||||
|
## Funding URL
|
||||||
|
|
||||||
|
You can include funding URLs where people who use your plugin can financially support it.
|
||||||
|
|
||||||
|
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fundingUrl": "https://buymeacoffee.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have multiple URLs, you can also do:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fundingUrl": {
|
||||||
|
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||||
|
"GitHub Sponsor": "https://github.com/sponsors",
|
||||||
|
"Patreon": "https://www.patreon.com/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
See https://github.com/obsidianmd/obsidian-api
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,25 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.2",
|
"@types/node": "^24.8.1",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.0",
|
||||||
"mini-css-extract-plugin": "^2.9.4",
|
"mini-css-extract-plugin": "^2.9.2",
|
||||||
"obsidian": "1.11.0",
|
"obsidian": "1.10.2",
|
||||||
"reconcile-text": "^0.11.0",
|
"reconcile-text": "^0.8.0",
|
||||||
"resolve-url-loader": "^5.0.0",
|
"resolve-url-loader": "^5.0.0",
|
||||||
"sass": "^1.96.0",
|
"sass": "^1.91.0",
|
||||||
"sass-loader": "^16.0.6",
|
"sass-loader": "^16.0.6",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"terser-webpack-plugin": "^5.3.16",
|
"terser-webpack-plugin": "^5.3.14",
|
||||||
"ts-loader": "^9.5.4",
|
"ts-loader": "^9.5.2",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.8.3",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"webpack": "^5.103.0",
|
"webpack": "^5.99.9",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack-cli": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
|
||||||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||||
...(IS_DEBUG_BUILD
|
...(IS_DEBUG_BUILD
|
||||||
? {
|
? {
|
||||||
fetch: debugging.slowFetchFactory(1),
|
fetch: debugging.slowFetchFactory(1),
|
||||||
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (IS_DEBUG_BUILD) {
|
if (IS_DEBUG_BUILD) {
|
||||||
debugging.logToConsole(client.logger);
|
debugging.logToConsole(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
|
|
@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
this.app.vault.on("create", (file: TAbstractFile) => {
|
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
client.syncLocallyCreatedFile(file.path);
|
await client.syncLocallyCreatedFile(file.path);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||||
|
|
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
|
||||||
await this.rateLimitedUpdate(file.path, client);
|
await this.rateLimitedUpdate(file.path, client);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.app.vault.on("delete", (file: TAbstractFile) => {
|
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||||
client.syncLocallyDeletedFile(file.path);
|
await client.syncLocallyDeletedFile(file.path);
|
||||||
}),
|
}),
|
||||||
this.app.vault.on(
|
this.app.vault.on(
|
||||||
"rename",
|
"rename",
|
||||||
(file: TAbstractFile, oldPath: string) => {
|
async (file: TAbstractFile, oldPath: string) => {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
client.syncLocallyUpdatedFile({
|
await client.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath: file.path
|
relativePath: file.path
|
||||||
});
|
});
|
||||||
|
|
@ -267,11 +267,13 @@ export default class VaultLinkPlugin extends Plugin {
|
||||||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||||
this.rateLimitedUpdatesPerFile.set(
|
this.rateLimitedUpdatesPerFile.set(
|
||||||
path,
|
path,
|
||||||
rateLimit(async () => {
|
rateLimit(
|
||||||
client.syncLocallyUpdatedFile({
|
async () =>
|
||||||
relativePath: path
|
client.syncLocallyUpdatedFile({
|
||||||
});
|
relativePath: path
|
||||||
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
|
}),
|
||||||
|
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ export function renderCursorsInFileExplorer(
|
||||||
app: App
|
app: App
|
||||||
): void {
|
): void {
|
||||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||||
if (fileExplorers.length == 0) {
|
if (fileExplorers.length == 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fileExplorer] = fileExplorers;
|
const [fileExplorer] = fileExplorers;
|
||||||
|
|
||||||
|
|
@ -36,7 +34,7 @@ export function renderCursorsInFileExplorer(
|
||||||
(parent) => {
|
(parent) => {
|
||||||
cursors.forEach((cursor) => {
|
cursors.forEach((cursor) => {
|
||||||
cursor.documentsWithCursors.forEach((document) => {
|
cursor.documentsWithCursors.forEach((document) => {
|
||||||
if (document.relativePath.startsWith(key)) {
|
if (document.relative_path.startsWith(key)) {
|
||||||
parent.appendChild(
|
parent.appendChild(
|
||||||
createSpan({
|
createSpan({
|
||||||
text: cursor.userName,
|
text: cursor.userName,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
||||||
return clientCursors.flatMap((cursor) =>
|
return clientCursors.flatMap((cursor) =>
|
||||||
cursor.cursors.map((span) => ({
|
cursor.cursors.map((span) => ({
|
||||||
name: client.userName,
|
name: client.userName,
|
||||||
path: cursor.relativePath,
|
path: cursor.relative_path,
|
||||||
deviceId: client.deviceId,
|
deviceId: client.deviceId,
|
||||||
isOutdated: client.isOutdated,
|
isOutdated: client.isOutdated,
|
||||||
span: { ...span }
|
span: { ...span }
|
||||||
|
|
@ -132,8 +132,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
edited,
|
edited
|
||||||
"Markdown"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
reconciled.cursors.forEach(({ id, position }) => {
|
reconciled.cursors.forEach(({ id, position }) => {
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,9 @@ export class SyncSettingsTab extends PluginSettingTab {
|
||||||
|
|
||||||
new Notice("Checking connection to the server...");
|
new Notice("Checking connection to the server...");
|
||||||
new Notice(
|
new Notice(
|
||||||
(await this.syncClient.checkConnection())
|
(
|
||||||
.serverMessage
|
await this.syncClient.checkConnection()
|
||||||
|
).serverMessage
|
||||||
);
|
);
|
||||||
await this.statusDescription.updateConnectionState();
|
await this.statusDescription.updateConnectionState();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -350,6 +351,22 @@ export class SyncSettingsTab extends PluginSettingTab {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Sync concurrency")
|
||||||
|
.setDesc(
|
||||||
|
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
||||||
|
)
|
||||||
|
.addSlider((text) =>
|
||||||
|
text
|
||||||
|
.setLimits(1, 16, 1)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.setInstant(false)
|
||||||
|
.setValue(this.syncClient.getSettings().syncConcurrency)
|
||||||
|
.onChange(async (value) =>
|
||||||
|
this.syncClient.setSetting("syncConcurrency", value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Maximum file size to be uploaded (MB)")
|
.setName("Maximum file size to be uploaded (MB)")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
|
|
@ -467,6 +484,40 @@ export class SyncSettingsTab extends PluginSettingTab {
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Minimum save interval (ms)")
|
||||||
|
.setDesc(
|
||||||
|
"The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance."
|
||||||
|
)
|
||||||
|
.addText((input) =>
|
||||||
|
input
|
||||||
|
.setValue(
|
||||||
|
this.syncClient
|
||||||
|
.getSettings()
|
||||||
|
.minimumSaveIntervalMs.toString()
|
||||||
|
)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
if (value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsedValue = Number.parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||||
|
parsedValue =
|
||||||
|
this.syncClient.getSettings()
|
||||||
|
.minimumSaveIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== parsedValue.toString()) {
|
||||||
|
input.setValue(parsedValue.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.syncClient.setSetting(
|
||||||
|
"minimumSaveIntervalMs",
|
||||||
|
parsedValue
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatusDescriptionSubscription(
|
private setStatusDescriptionSubscription(
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export class StatusDescription {
|
||||||
text: ` and has indexed approximately `
|
text: ` and has indexed approximately `
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.syncClient.syncedDocumentCount}`,
|
text: `${this.syncClient.documentCount}`,
|
||||||
cls: "number"
|
cls: "number"
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"lib": ["DOM", "ES2024"]
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ES2024"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exclude": ["./dist"]
|
"exclude": [
|
||||||
|
"./dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ module.exports = (env, argv) => ({
|
||||||
const source = path.resolve(__dirname, "dist");
|
const source = path.resolve(__dirname, "dist");
|
||||||
const destinations = [
|
const destinations = [
|
||||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||||
|
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||||
];
|
];
|
||||||
destinations.forEach((destination) => {
|
destinations.forEach((destination) => {
|
||||||
fs.copy(source, destination)
|
fs.copy(source, destination)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue