Compare commits

..

4 commits

Author SHA1 Message Date
3160e850ca .
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
2026-05-09 10:14:50 +01:00
9e81343ab1 Improve tests
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
2026-05-09 10:14:29 +01:00
20e1c3f22d Improve tests
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
2026-05-09 09:06:12 +01:00
a33e4bbcb9 Add deterministic-tests workspace
Scripted multi-client harness against a real server (~110 scenario
tests, server-control, managed-websocket, test-runner). Wires the new
package into frontend/package.json workspaces and the lint script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:11:16 +01:00
58 changed files with 1188 additions and 719 deletions

27
.github/dependabot.yml vendored Normal file
View 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
View 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
View 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
View 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

View 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
View 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 \
*

View 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}

View file

@ -3,9 +3,15 @@ import type { TestDefinition } from "../test-definition";
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
description:
"Client 0 sends three rapid updates. After syncing, both clients " +
"disconnect and reconnect twice. Content should remain correct " +
"after each reconnect.",
"Probes that the watermark advances correctly through coalesced " +
"remote updates. Client 0 sends three rapid updates, all observed " +
"by Client 1 (their vault_update_ids may coalesce in the engine's " +
"MinCovered). Then Client 1 disconnects and Client 0 issues one " +
"MORE update while Client 1 is offline. On Client 1's reconnect, " +
"catch-up uses last_seen_vault_update_id — if the coalesced " +
"updates wrongly advanced the watermark past Client 0's offline " +
"update's id, that update is silently lost. The final assert " +
"pins Client 1 receiving the post-reconnect update via catch-up.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
@ -13,40 +19,40 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Three rapid updates — coalesce in engine queues; both clients
// converge to "final update".
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
}
},
{ type: "disable-sync", client: 0 },
// Client 1 goes offline.
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
// Client 0 issues a follow-up edit while c1 is offline. This
// event has a vault_update_id strictly greater than the
// coalesced sequence; if c1's watermark is correct, catch-up
// will return it. If the watermark wrongly advanced past it
// during the coalesce (too-new), catch-up returns nothing and
// c1 silently misses this edit.
{
type: "update",
client: 0,
path: "doc.md",
content: "post-reconnect edit"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
}
},
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
s.assertFileCount(1).assertContent(
"doc.md",
"post-reconnect edit"
);
}
}
]

View file

@ -29,7 +29,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertAnyFileContains("the-content");
// The rename must land at renamed.md on both clients.
// assertAnyFileContains alone would have passed even if
// the rename were dropped server-side and both clients
// converged on doc.md with "the-content".
s.assertFileCount(1)
.assertContent("renamed.md", "the-content")
.assertFileNotExists("doc.md");
}
}
]

View file

@ -33,7 +33,10 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "recreated by client 0");
s.assertFileCount(1).assertContent(
"A.md",
"recreated by client 0"
);
}
}
]

View file

@ -35,7 +35,14 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileExists("A.md").assertContains("A.md", "recreated");
// The description commits to "client 0's recreated content
// preserved" — so pin to a single file at A.md with the
// recreated content. A deconflicted "updated by client 1"
// file would slip past assertContains/assertFileExists.
s.assertFileCount(1).assertContent(
"A.md",
"recreated by client 0"
);
}
}
]

View file

@ -3,29 +3,45 @@ import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = {
description:
"Client 0 creates a file, then the server is paused mid-response. " +
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
"The server commits Client 0's create but Client 0 never sees the " +
"response — simulating a connection drop after server-side commit. " +
"drop-next-create-response intercepts the response in the client's " +
"fetch wrapper after the server has already processed the POST, " +
"raising SyncResetError. The client's offline-scan retry must be " +
"idempotent: server-side dedup of the retried create + the " +
"already-committed doc must NOT produce a duplicate file. " +
"(Earlier version did `create -> pause -> resume`, where the " +
"create could complete cleanly before the pause and the " +
"idempotency path was never exercised.)",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Arm the interceptor BEFORE the create so the very first POST
// /documents from c0 has its response dropped after server commit.
{ type: "drop-next-create-response", client: 0 },
{
type: "create",
client: 0,
path: "doc.md",
content: "important data"
},
{ type: "pause-server" },
{ type: "resume-server" },
// Block until the server has committed and the client has been
// notified the response was dropped — deterministic happens-before
// for "server has the doc, client thinks the create failed".
{ type: "wait-for-dropped-create-response", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
// No duplicate doc despite the client's retry: server-side
// path-collision merge or idempotency must collapse them.
s.assertFileCount(1).assertContent("doc.md", "important data");
}
}

View file

@ -3,8 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const interruptedDeleteRetryTest: TestDefinition = {
description:
"Client 0 deletes a file, then the server is paused. " +
"After the server resumes, both clients should have zero files.",
"Client 0's delete HTTP is interrupted (server paused) and must " +
"retry on resume. Pause is established BEFORE the delete is " +
"issued so the DELETE is deterministically in-flight against a " +
"frozen server — the earlier ordering (delete then pause) raced " +
"the request: under fast scheduling the DELETE could commit " +
"before SIGSTOP and the test reduced to a trivial " +
"create-then-delete with no interruption at all.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
@ -12,11 +17,10 @@ export const interruptedDeleteRetryTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "pause-server" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "resume-server" },
{ type: "barrier" },
{

View file

@ -3,18 +3,35 @@ import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = {
description:
"Both clients create doc.md with different content while offline. " +
"Client 0 also edits the file before syncing. After both connect, " +
"the merged result should contain content from both clients.",
"Client 0's create is in-flight (server paused) when Client 0 " +
"writes a follow-up local edit. The wire-loop will receive the " +
"create response, then must apply the queued local update against " +
"the now-resolved doc id rather than discarding it as a stale " +
"pending-create. Client 1's pre-existing same-path doc forces a " +
"server-side merge, exercising the path where the local edit must " +
"be re-attached to the merged doc id via replacePendingDocumentId.",
clients: 2,
steps: [
// Client 1 creates and syncs doc.md first — this is the doc the
// server will merge Client 0's later create into.
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{
type: "create",
client: 0,
path: "doc.md",
content: "from-client-0"
},
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
// Client 0 starts offline so the create that follows queues into
// the engine rather than firing immediately.
{ type: "create", client: 0, path: "doc.md", content: "from-client-0" },
// Pause the server so c0's POST /documents will hang once it goes.
{ type: "pause-server" },
{ type: "enable-sync", client: 0 },
// While the create's HTTP is in-flight against the paused server,
// c0's local edit lands. This is the queue-coalesce scenario the
// engine must survive: the LocalUpdate carries a Promise<DocId>
// chained off the pending create, and replacePendingDocumentId
// must rewire it once the server's merge response resolves to
// Client 1's existing doc id.
{
type: "update",
client: 0,
@ -22,17 +39,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "local-edit-during-create"
},
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
// The local edit must NOT be lost in the create-merge
// collapse. assertContent (not assertContains) pins the
// exact post-merge state — we accept either pure local
// content (replace-wins) or a true merge containing both
// contributions, but the local edit must be present.
s.assertFileCount(1).assertContains(
"doc.md",
"from-client-1",
"local-edit-during-create"
);
}

View file

@ -30,6 +30,11 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
s.assertContent("C.md", "unrelated").assertFileNotExists(
"A.md"
);
// The offline-renamed file must survive — either as B.md
// (rename preserved) or as a deconflict carrying "original".
// A regression that silently drops both leaves only C.md,
// which we explicitly forbid here.
s.assertAnyFileContains("original");
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "original")
);

View file

@ -40,6 +40,10 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
.assertFileExists("file-5.md")
.assertFileNotExists("file-2.md")
.assertFileNotExists("file-4.md");
// The offline rename of a remotely-deleted file must
// preserve "content-2" somewhere — either at renamed.md
// or as a deconflict.
s.assertAnyFileContains("content-2");
s.ifFileExists("renamed.md", (inner) =>
inner.assertContent("renamed.md", "content-2")
);

View file

@ -3,8 +3,11 @@ import type { TestDefinition } from "../test-definition";
export const multiFileOperationsTest: TestDefinition = {
description:
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md " +
"and renames its stale A.md to D.md offline. After client 1 reconnects, " +
"B.md must hold client 1's update, C.md must be unchanged, A.md must be " +
"gone, and the offline-renamed file must be preserved at D.md (post as " +
"a new doc since A.md was deleted server-side).",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
@ -33,12 +36,14 @@ export const multiFileOperationsTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContains("B.md", "updated")
.assertFileExists("C.md")
// Pin B.md/C.md/D.md exactly: a regression that loses
// client 1's offline rename (no D.md) or that drops the
// B.md update would otherwise pass the loose checks.
s.assertFileCount(3)
.assertContent("B.md", "updated by client 1")
.assertContent("C.md", "content-c")
.assertContent("D.md", "content-a")
.assertFileNotExists("A.md");
s.ifFileExists("D.md", (inner) =>
inner.assertContent("D.md", "content-a")
);
}
}
]

View file

@ -44,9 +44,22 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
// Two concurrent offline renames of the same source:
// exactly one file must remain (count 1), it must hold
// "shared-content", and that file must be one of the two
// rename targets — neither rename may silently land at
// some unrelated path.
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertAnyFileContains("shared-content");
if (
!s.files.has("B.md") &&
!s.files.has("C.md")
) {
throw new Error(
`Expected the surviving file to be B.md or C.md. Files: [${Array.from(s.files.keys()).join(", ")}]`
);
}
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "shared-content")
);

View file

@ -29,9 +29,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertFileNotExists(
"A_renamed.md"
);
// Delete+rename: client 0's offline delete must propagate.
// Final state is no files; require it explicitly so a
// regression producing some divergent identical filename
// on both clients can't slip past the per-name checks.
s.assertFileNotExists("A.md")
.assertFileNotExists("A_renamed.md")
.assertFileCount(0);
}
}
]

View file

@ -3,7 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const offlineEditThenMoveSameContentTest: TestDefinition = {
description:
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
"Single-client offline sequence on Client 0: delete A.md, rename " +
"B.md to C.md, then update C.md so its content equals the deleted " +
"A.md's content. The ambiguity is for the engine: the resulting " +
"C.md content matches a doc that was just deleted, but it must " +
"still be tracked as the renamed-from-B doc, not resurrected as A. " +
"Both clients must converge to a single C.md with 'content A'.",
clients: 2,
steps: [
{

View file

@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
description:
"Offline-rename vs. concurrent remote-update of the same doc. " +
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " +
"(same document). When Client 0 reconnects, the rename and update " +
"should merge. Y.md should exist with Client 1's content.",
"(same document) and syncs. When Client 0 reconnects, the rename " +
"and update must merge: Y.md must hold Client 1's updated content. " +
"(Filename is legacy — there is no remote create at the old path; " +
"this is the rename-vs-update mirror of offline-edit-remote-rename.)",
clients: 2,
steps: [
{ type: "create", client: 0, path: "X.md", content: "original" },
@ -41,7 +44,9 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
// Pin exact content + path: rename and update must
// collapse onto Y.md with Client 1's update.
s.assertFileCount(1).assertContent(
"Y.md",
"updated-by-client-1"
);

View file

@ -65,10 +65,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent(
"A.md",
"A updated by client 0"
).assertFileNotExists("B.md");
// Delete must win for B.md: pin file count to 1 so a
// deconflict carrying client 1's update of B.md cannot
// silently sneak in.
s.assertFileCount(1)
.assertContent("A.md", "A updated by client 0")
.assertFileNotExists("B.md");
}
}
]

View file

@ -30,7 +30,7 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "round 3");
s.assertFileCount(1).assertContent("A.md", "round 3");
}
}
]

View file

@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
description:
"Client 0 goes offline, both clients edit doc.md concurrently, " +
"then client 0 reconnects. Both edits must be preserved.",
"Client 0's local update is queued in the wire loop while the " +
"server is paused (so the POST hangs), then disable-sync forces a " +
"SyncReset that clears the wire-loop queue. On re-enable, the " +
"engine MUST rediscover the disk content via offline scan and " +
"merge it with the meantime remote update — otherwise the " +
"queue-reset has silently lost a coalesced local edit. (Earlier " +
"version of this test ran the local update while sync was already " +
"disabled, so there was no queue to reset.)",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
@ -12,13 +18,20 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "alpha bravo" },
{ type: "sync", client: 1 },
// Pause the server so c0's wire loop can enqueue but cannot drain.
{ type: "pause-server" },
// c0's update is queued in the wire loop; the POST will hang.
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
// disable-sync triggers a SyncReset — the in-flight POST aborts
// with SyncResetError and the wire-loop queue is cleared.
{ type: "disable-sync", client: 0 },
{ type: "resume-server" },
// Re-enable. Offline scan must see disk content "charlie delta"
// and merge it with the server's "alpha bravo".
{ type: "enable-sync", client: 0 },
{ type: "barrier" },

View file

@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const renameCreateConflictTest: TestDefinition = {
description:
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.",
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and " +
"syncs. Client 0 (offline) creates B.md with the same content. After " +
"reconnecting, both clients converge to two files: B.md (from the " +
"rename, content 'hi') and a deconflicted B (1).md (the offline " +
"create, content 'hi') — content dedup is content-hash + parent " +
"version based, not body-only, so even identical content from " +
"different doc lineages produces a distinct file.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },

View file

@ -33,10 +33,14 @@ export const renameToPendingPathFallbackTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("B.md").assertContains(
"A.md",
"tracked B content"
);
// The rename clobbers the unsynced A.md on disk, so the
// expected post-converge state is exactly one file at A.md
// with the renamed-from-B content. A regression that
// produced a deconflicted A (1).md carrying the lost
// "pending A content" would slip past assertContains.
s.assertFileNotExists("B.md")
.assertFileCount(1)
.assertContent("A.md", "tracked B content");
}
}
]

View file

@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
description:
"Client 0 deletes a file. Client 1 toggles sync off and on " +
"(simulating reconnect). The deleted file should NOT reappear " +
"on Client 1 after the sync reset.",
"Client 0 deletes a file. Client 1 calls `reset` (clears tracked " +
"state — recently-deleted set, watermark, doc records — but keeps " +
"disk files). After the reset and re-handshake, the deleted file " +
"should NOT reappear via offline-scan resurrection. This is the " +
"stronger probe than disable/enable-sync, which keeps the " +
"recently-deleted suppression set intact.",
clients: 2,
steps: [
{
@ -28,8 +31,7 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
}
},
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 1 },
{ type: "reset", client: 1 },
{ type: "barrier" },

View file

@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const serverPauseBothClientsCreateTest: TestDefinition = {
description:
"Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.",
"Client 0 creates and FULLY syncs alpha.md before the server is " +
"paused, then Client 1 creates beta.md while the server is paused. " +
"After resume, both clients must hold both files. The `sync` after " +
"Client 0's create is required: without it the create is fire-" +
"and-forget and SIGSTOP can land before the POST hits the server, " +
"reducing the test to two creates against a paused server (a " +
"different scenario from the named one).",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
@ -16,6 +22,10 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
path: "alpha.md",
content: "from client 0"
},
// Deterministic happens-before: alpha.md is on the server before
// SIGSTOP. Without this, the test races the in-flight POST.
{ type: "sync", client: 0 },
{ type: "pause-server" },
{
@ -26,16 +36,14 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContains("alpha.md", "from client 0").assertContains(
"beta.md",
"from client 1"
);
s.assertFileCount(2)
.assertContent("alpha.md", "from client 0")
.assertContent("beta.md", "from client 1");
}
}
]

View file

@ -58,7 +58,11 @@ export const serverPauseBothEditSameFileTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
// Post-merge update must REPLACE the merged content, not
// append to it. assertContent pins the exact state so a
// regression that re-merges the new write with leftover
// markers from the previous merge is caught.
s.assertFileCount(1).assertContent(
"shared.md",
"post-merge edit from client 0"
);

View file

@ -3,9 +3,14 @@ import type { TestDefinition } from "../test-definition";
export const simultaneousCreateDeleteSamePathTest: TestDefinition = {
description:
"Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " +
"Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " +
"the update and delete must be reconciled. Both clients must converge.",
"Client 0 creates A.md and syncs. Client 1 disables sync. Client 0 " +
"deletes A.md and that delete reaches the server before Client 1 " +
"reconnects. While offline, Client 1 updates A.md. On reconnect, " +
"Client 1's update lands against an already-deleted server doc — " +
"delete must win, both clients converge to zero files. (Filename is " +
"legacy: there is no 'create' here; the scenario is online-delete " +
"vs. offline-update, distinct from update-survives-remote-delete " +
"where both clients are offline at delete time.)",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "original from 0" },

View file

@ -49,6 +49,10 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
s.assertFileNotExists("X.md").assertAnyFileContains(
"new from C"
);
// Each contributing client's content must appear in at
// most one file (no silent duplication via remote replay).
s.assertContentInAtMostOneFile("new from C");
s.assertContentInAtMostOneFile("original from A");
}
}
]

View file

@ -1,7 +1,7 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = {
export const updateDoesNotSurviveRemoteDeleteTest: TestDefinition = {
description:
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
clients: 2,

View file

@ -3,7 +3,15 @@ import type { TestDefinition } from "../test-definition";
export const watermarkAdvancesOnSkipTest: TestDefinition = {
description:
"Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.",
"Probes that the watermark advances past 'skip' branches — events " +
"the client receives but treats as already-applied (e.g. an " +
"offline-create that the server merged into another doc). Both " +
"clients create the same path offline and reconnect; one becomes " +
"the canonical doc and the other's create is skipped via merge. " +
"Then Client 1 disconnects, Client 0 issues a follow-up update, " +
"and on Client 1's reconnect catch-up MUST deliver it. If the " +
"skip branch failed to advance lastSeenUpdateId, catch-up either " +
"wedges in re-replay (would time out) or misses the follow-up.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
@ -19,16 +27,27 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
// Now exercise the skip-then-receive path. Disconnect c1, have
// c0 push a new update, reconnect c1 — c1's catch-up must
// deliver the update past the skipped event.
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "post-skip update"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertFileExists("doc.md");
s.assertFileCount(1).assertContent(
"doc.md",
"post-skip update"
);
}
}
]

View file

@ -3,7 +3,16 @@ import type { TestDefinition } from "../test-definition";
export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
description:
"Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.",
"Probes that the watermark records every observed remote update, " +
"even when two arrive in close succession with intervening " +
"vault_update_ids the client also sees. Client 0 sends two " +
"updates with `sync` between them so they hit the wire as two " +
"distinct broadcasts. Client 1 processes both. Then Client 1 " +
"disconnects, Client 0 issues a third update while Client 1 is " +
"offline, and on reconnect Client 1's catch-up MUST deliver the " +
"third update — if the gap-update was not recorded into " +
"lastSeenUpdateId, catch-up requests events newer than the " +
"incorrectly-advanced watermark and silently misses the third.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
@ -14,23 +23,30 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "sync", client: 0 },
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "update 2");
}
},
{ type: "disable-sync", client: 1 },
// Issued while c1 is offline. Catch-up MUST deliver this on
// reconnect; a too-new watermark would silently skip it.
{
type: "update",
client: 0,
path: "doc.md",
content: "offline-period update"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "update 2");
s.assertFileCount(1).assertContent(
"doc.md",
"offline-period update"
);
}
}
]

View file

@ -1,4 +1,4 @@
FROM node:25-slim AS builder
FROM node:22-slim AS builder
WORKDIR /build
@ -7,7 +7,7 @@ COPY . .
RUN npm ci
RUN npm run build
FROM node:25-alpine
FROM node:22-alpine
LABEL org.opencontainers.image.title="VaultLink Local CLI"
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"

View file

@ -47,25 +47,24 @@ vaultlink \
### Required
| Option | Description |
| ------------------------- | --------------------------------------------- |
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
| Option | Description |
|--------|-------------|
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
### Optional
| Option | Default | Description |
| ------------------------------------ | ------- | ----------------------------------------------- |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
| Option | Default | Description |
|--------|---------|-------------|
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
### Auto-Ignored Patterns
@ -75,32 +74,22 @@ vaultlink \
### Examples
Basic usage:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
```
With ignore patterns:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--ignore-pattern "**/*.tmp" \
--ignore-pattern "*.tmp" \
--ignore-pattern ".DS_Store" \
--ignore-pattern "node_modules/**"
```
With debug logging and quiet startup:
With debug logging:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--log-level DEBUG --quiet
```
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
--log-level DEBUG
```
## Docker Deployment
@ -187,7 +176,6 @@ services:
## Development
Build:
```bash
npm run build
# or from the parent folder, run
@ -195,13 +183,11 @@ docker build -f local-client-cli/Dockerfile .
```
Test:
```bash
npm test
```
Docker build:
```bash
cd frontend
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .

View file

@ -11,16 +11,18 @@
"build": "webpack --mode production",
"test": "tsx --test 'src/**/*.test.ts'"
},
"devDependencies": {
"dependencies": {
"commander": "^14.0.2",
"watcher": "^2.3.1",
"@types/node": "^25.0.2",
"watcher": "^2.3.1"
},
"devDependencies": {
"@types/node": "^24.8.1",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -55,10 +55,13 @@ test("parseArgs - parse with optional arguments", () => {
"mytoken",
"-v",
"default",
"--sync-concurrency",
"5",
"--max-file-size-mb",
"20"
]);
assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20);
});
@ -150,6 +153,25 @@ test("parseArgs - default log level is 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", () => {
const args = parseArgs([
"node",
@ -169,32 +191,28 @@ test("parseArgs - parse ERROR log level", () => {
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", () => {
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;
}
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - CLI arguments take precedence over environment variables", () => {
process.env.VAULTLINK_TOKEN = "env-token";
try {
const args = parseArgs([
test("parseArgs - throws on invalid log level", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
@ -202,12 +220,11 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
"-r",
"https://sync.example.com",
"-t",
"cli-token",
"mytoken",
"-v",
"default"
"default",
"--log-level",
"INVALID"
]);
assert.equal(args.token, "cli-token");
} finally {
delete process.env.VAULTLINK_TOKEN;
}
}, /Invalid log level/);
});

View file

@ -1,54 +1,19 @@
import { Command, Option } from "commander";
import { Command } from "commander";
import packageJson from "../package.json";
import { LogLevel } from "sync-client";
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
interface CliArgs {
export interface CliArgs {
remoteUri: string;
token: string;
vaultName: string;
localPath: string;
syncConcurrency?: number;
maxFileSizeMB?: number;
ignorePatterns?: string[];
webSocketRetryIntervalMs?: number;
logLevel: LogLevel;
health?: string;
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 {
@ -60,85 +25,41 @@ export function parseArgs(argv: string[]): CliArgs {
"VaultLink Local CLI - Sync your vault to the local filesystem"
)
.version(packageJson.version)
.addOption(
new Option(
REQUIRED_OPTIONS.localPath.flags,
"Local directory path to sync"
).env(REQUIRED_OPTIONS.localPath.env)
.option("-l, --local-path <path>", "Local directory path to sync")
.option("-r, --remote-uri <uri>", "Remote server URI")
.option("-t, --token <token>", "Authentication token")
.option("-v, --vault-name <name>", "Vault name")
.option(
"--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations",
parseInt
)
.addOption(
new Option(
REQUIRED_OPTIONS.remoteUri.flags,
"Remote server URI"
).env(REQUIRED_OPTIONS.remoteUri.env)
.option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB",
parseInt
)
.addOption(
new Option(
REQUIRED_OPTIONS.token.flags,
"Authentication token"
).env(REQUIRED_OPTIONS.token.env)
.option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
)
.addOption(
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
REQUIRED_OPTIONS.vaultName.env
)
.option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt
)
.addOption(
new Option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
.option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO"
)
.addOption(
new Option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
).env("VAULTLINK_IGNORE_PATTERNS")
.option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
)
.addOption(
new Option(
"--websocket-retry-interval-ms <number>",
"[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")
.option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
)
.addHelpText(
"after",
@ -146,13 +67,9 @@ export function parseArgs(argv: string[]): CliArgs {
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"
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--log-level DEBUG --quiet
Environment variables:
All options can be configured via VAULTLINK_ prefixed environment variables.
CLI arguments take precedence over environment variables.
--log-level DEBUG
`
);
@ -164,6 +81,7 @@ Environment variables:
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
@ -172,23 +90,22 @@ Environment variables:
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
const health = opts.health as string | 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 */
const requiredLocalPath = requireOption(localPath, "localPath");
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))
) {
if (localPath === undefined) {
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
const logLevelUpper = logLevelStr.toUpperCase();
@ -203,27 +120,17 @@ Environment variables:
}
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 {
localPath: requiredLocalPath,
remoteUri: requiredRemoteUri,
token: requiredToken,
vaultName: requiredVaultName,
localPath,
remoteUri,
token,
vaultName,
syncConcurrency,
maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel,
health,
enableTelemetry,
quiet,
lineEndings
enableTelemetry
};
}

View file

@ -5,27 +5,24 @@ import type { NetworkConnectionStatus } from "sync-client";
import {
SyncClient,
DEFAULT_SETTINGS,
Logger,
LogLevel,
LogLine,
type SyncSettings,
type StoredDatabase
} from "sync-client";
import { parseArgs, type LineEndingMode } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem";
import { FileWatcher } from "./file-watcher";
import { formatLogLine } from "./logger-formatter";
import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json";
function writeHealthStatus(
logger: Logger,
filePath: string,
connectionStatus: NetworkConnectionStatus
): void {
try {
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
} catch (error) {
logger.error(
console.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
};
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 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> {
const args = parseArgs(process.argv);
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)) {
fsSync.mkdirSync(absolutePath, { recursive: true });
}
@ -80,31 +48,38 @@ async function main(): Promise<void> {
try {
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
console.error(
colorize(`Error: ${absolutePath} is not a directory`, "red")
);
process.exit(1);
}
} catch (error) {
emitBoot(
LogLevel.ERROR,
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
console.error(
colorize(
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
}
if (!args.quiet) {
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
if (args.lineEndings !== "auto") {
emitBoot(
LogLevel.INFO,
`Line endings: ${args.lineEndings.toUpperCase()}`
);
}
}
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_DIR);
const dataDir = path.join(absolutePath, ".vaultlink");
const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true });
@ -113,7 +88,8 @@ async function main(): Promise<void> {
const ignorePatterns = [
...(args.ignorePatterns ?? []),
`${VAULTLINK_DIR}/**`
".vaultlink/**",
".git/**"
];
const settings: SyncSettings = {
@ -121,6 +97,8 @@ async function main(): Promise<void> {
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
@ -141,9 +119,11 @@ async function main(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>;
} catch {
emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
console.error(
colorize(
`Cannot read data file at ${dataFile}`,
"yellow"
)
);
}
@ -153,27 +133,23 @@ async function main(): Promise<void> {
};
},
save: async ({ database: persistedDatabase }) => {
// settings can't be updated when running with this CLI
await fs.writeFile(
dataFile,
JSON.stringify(persistedDatabase, null, 2)
);
}
},
nativeLineEndings: resolveLineEndings(args.lineEndings)
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
});
if (args.health !== undefined) {
const healthFile = args.health;
const writeHealth = (): void => {
const healthInterval = setInterval(() => {
void client.checkConnection().then((status) => {
writeHealthStatus(client.logger, healthFile, status);
writeHealthStatus(healthFile, status);
});
};
writeHealth();
const healthInterval = setInterval(
writeHealth,
HEALTH_CHECK_INTERVAL_MS
);
}, HEALTH_CHECK_INTERVAL_MS);
const clearHealthInterval = (): void => {
clearInterval(healthInterval);
};
@ -182,10 +158,17 @@ async function main(): Promise<void> {
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");
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
const fileWatcher = new FileWatcher(absolutePath, client);
client.onWebSocketStatusChanged.add(() => {
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) => {
if (remaining > syncBatchSize) {
syncBatchSize = remaining;
}
if (remaining === 0) {
if (syncBatchSize > 0) {
totalSyncOps += syncBatchSize;
client.logger.info(
`Sync batch complete (${syncBatchSize} operations)`
);
syncBatchSize = 0;
}
client.logger.info("All sync operations completed");
} else {
const now = Date.now();
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
client.logger.info(
`Syncing: ${remaining} operations remaining`
);
lastProgressLogTime = now;
}
client.logger.info(`${remaining} sync operations remaining`);
}
});
let isShuttingDown = false;
const gracefulShutdown = async (signal: string): Promise<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
client.logger.info(`${signal} received, shutting down gracefully`);
console.log(
colorize(
`\n${signal} received. Shutting down gracefully...`,
"yellow"
)
);
fileWatcher.stop();
await client.waitUntilFinished();
await client.destroy();
if (totalSyncOps > 0) {
client.logger.info(
`Shutdown complete (${totalSyncOps} operations synced)`
);
} else {
client.logger.info("Shutdown complete");
}
console.log(colorize("Shutdown complete", "green"));
process.exit(0);
};
@ -255,21 +210,27 @@ async function main(): Promise<void> {
try {
const connectionStatus = await client.checkConnection();
if (!connectionStatus.isSuccessful) {
client.logger.error(
`Cannot connect to server: ${connectionStatus.serverMessage}`
console.error(
colorize(
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
"red"
)
);
process.exit(1);
}
if (!args.quiet) {
client.logger.info("Server connection successful");
}
console.log(`${colorize("✓", "green")} Server connection successful`);
console.log(colorize("Press Ctrl+C to stop", "dim"));
console.log("");
await client.start();
fileWatcher.start();
} catch (error) {
client.logger.error(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
console.error(
colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
fileWatcher.stop();
@ -279,9 +240,11 @@ async function main(): Promise<void> {
}
main().catch((error: unknown) => {
// eslint-disable-next-line no-console
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);
});

View file

@ -1,20 +1,15 @@
import Watcher from "watcher";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
import { toUnixPath, matchesGlob } from "./path-utils";
export class FileWatcher {
private watcher: Watcher | undefined;
private isRunning = false;
private readonly ignorePatterns: string[];
public constructor(
private readonly basePath: string,
private readonly client: SyncClient,
ignorePatterns: string[] = []
) {
this.ignorePatterns = ignorePatterns;
}
private readonly client: SyncClient
) {}
public start(): void {
if (this.isRunning) {
@ -27,8 +22,7 @@ export class FileWatcher {
recursive: true,
renameDetection: true,
renameTimeout: 125,
ignoreInitial: true,
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
ignoreInitial: true
});
this.watcher.on("add", (filePath: string) => {
@ -62,32 +56,66 @@ export class FileWatcher {
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 {
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 {
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 {
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 {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
this.client
.syncLocallyUpdatedFile({
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 {
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);
}
}

View file

@ -1,5 +1,4 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Healthcheck script for Docker container

View file

@ -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."));
});

View file

@ -1,21 +1,36 @@
import { LogLevel, type LogLine } from "sync-client";
const colors = {
// 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;
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}`;
}
/**
* 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");

View file

@ -1,32 +1,31 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import type {
FileSystemOperations,
RelativePath,
TextWithCursors
} 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 {
public constructor(private readonly basePath: string) { }
public constructor(private readonly basePath: string) {}
public async listFilesRecursively(
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(directory ?? "", files);
await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "",
files
);
return files;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
return await fs.readFile(fullPath);
} catch (error) {
@ -40,12 +39,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const dir = path.dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await this.atomicWrite(fullPath, content);
await fs.writeFile(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
@ -57,12 +59,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(this.basePath, relativePath);
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 this.atomicWrite(fullPath, result.text, "utf-8");
await fs.writeFile(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
@ -72,7 +77,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const stats = await fs.stat(fullPath);
return stats.size;
@ -84,7 +92,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.access(fullPath);
return true;
@ -94,7 +105,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
@ -105,7 +119,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.unlink(fullPath);
} catch (error) {
@ -119,8 +136,14 @@ export class NodeFileSystemOperations implements FileSystemOperations {
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(this.basePath, oldPath);
const newFullPath = path.join(this.basePath, newPath);
const oldFullPath = path.join(
this.basePath,
this.toNativePath(oldPath)
);
const newFullPath = path.join(
this.basePath,
this.toNativePath(newPath)
);
const newDir = path.dirname(newFullPath);
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(
relativePath: string,
files: RelativePath[]
@ -194,8 +179,28 @@ export class NodeFileSystemOperations implements FileSystemOperations {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// 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;
}
}

View file

@ -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");
});

View file

@ -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);
}

View file

@ -18,5 +18,7 @@
"declarationMap": true,
"sourceMap": true
},
"exclude": ["dist"]
"exclude": [
"dist"
]
}

View file

@ -2,32 +2,32 @@ const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
]
};

View file

@ -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!
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 command "Open Sample Modal" which opens a Modal.
- 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/`.
## 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
See https://github.com/obsidianmd/obsidian-api

View file

@ -13,25 +13,25 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.0.2",
"@types/node": "^24.8.1",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.2",
"mini-css-extract-plugin": "^2.9.4",
"obsidian": "1.11.0",
"reconcile-text": "^0.11.0",
"fs-extra": "^11.3.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.10.2",
"reconcile-text": "^0.8.0",
"resolve-url-loader": "^5.0.0",
"sass": "^1.96.0",
"sass": "^1.91.0",
"sass-loader": "^16.0.6",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.16",
"ts-loader": "^9.5.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"url": "^0.11.4",
"webpack": "^5.103.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
: {})
});
if (IS_DEBUG_BUILD) {
debugging.logToConsole(client.logger);
debugging.logToConsole(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) {
client.syncLocallyCreatedFile(file.path);
await client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
await this.rateLimitedUpdate(file.path, client);
}
}),
this.app.vault.on("delete", (file: TAbstractFile) => {
client.syncLocallyDeletedFile(file.path);
this.app.vault.on("delete", async (file: TAbstractFile) => {
await client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
(file: TAbstractFile, oldPath: string) => {
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
client.syncLocallyUpdatedFile({
await client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
@ -267,11 +267,13 @@ export default class VaultLinkPlugin extends Plugin {
if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set(
path,
rateLimit(async () => {
client.syncLocallyUpdatedFile({
relativePath: path
});
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
rateLimit(
async () =>
client.syncLocallyUpdatedFile({
relativePath: path
}),
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}
await this.rateLimitedUpdatesPerFile.get(path)?.();

View file

@ -14,9 +14,7 @@ export function renderCursorsInFileExplorer(
app: App
): void {
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
if (fileExplorers.length == 0) {
return;
}
if (fileExplorers.length == 0) return;
const [fileExplorer] = fileExplorers;
@ -36,7 +34,7 @@ export function renderCursorsInFileExplorer(
(parent) => {
cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => {
if (document.relativePath.startsWith(key)) {
if (document.relative_path.startsWith(key)) {
parent.appendChild(
createSpan({
text: cursor.userName,

View file

@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relativePath,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...span }
@ -132,8 +132,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
]
)
},
edited,
"Markdown"
edited
);
reconciled.cursors.forEach(({ id, position }) => {

View file

@ -266,8 +266,9 @@ export class SyncSettingsTab extends PluginSettingTab {
new Notice("Checking connection to the server...");
new Notice(
(await this.syncClient.checkConnection())
.serverMessage
(
await this.syncClient.checkConnection()
).serverMessage
);
await this.statusDescription.updateConnectionState();
} 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)
.setName("Maximum file size to be uploaded (MB)")
.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(

View file

@ -88,7 +88,7 @@ export class StatusDescription {
text: ` and has indexed approximately `
});
container.createSpan({
text: `${this.syncClient.syncedDocumentCount}`,
text: `${this.syncClient.documentCount}`,
cls: "number"
});
container.createSpan({

View file

@ -6,7 +6,12 @@
"strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"lib": ["DOM", "ES2024"]
"lib": [
"DOM",
"ES2024"
]
},
"exclude": ["./dist"]
"exclude": [
"./dist"
]
}

View file

@ -46,7 +46,8 @@ module.exports = (env, argv) => ({
const source = path.resolve(__dirname, "dist");
const destinations = [
"/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) => {
fs.copy(source, destination)