mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-15 12:01:16 +00:00
Compare commits
29 Commits
feat-userh
...
update_lib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45aca4794 | ||
|
|
e2c54aaf43 | ||
|
|
8dda24a689 | ||
|
|
fbbb63906a | ||
|
|
1e66a7f144 | ||
|
|
df79d81475 | ||
|
|
ad71355859 | ||
|
|
95dc079fad | ||
|
|
67996f6d0a | ||
|
|
4ab2e41d18 | ||
|
|
c0ad8ee15a | ||
|
|
e6ae516493 | ||
|
|
a4d5ef4620 | ||
|
|
3f7bb047ac | ||
|
|
b6b153c0de | ||
|
|
eca6a6e0ba | ||
|
|
ca43d96c46 | ||
|
|
112e3c8b1d | ||
|
|
d1eb105801 | ||
|
|
d5b93e89cd | ||
|
|
e96fe7cde1 | ||
|
|
68e0610f1d | ||
|
|
a6be20695a | ||
|
|
772b6ecf26 | ||
|
|
81dc7f604b | ||
|
|
a9c87fa52e | ||
|
|
e81f023943 | ||
|
|
7a4b76a550 | ||
|
|
f9294446ba |
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Issue report
|
name: Issue report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: 'bug'
|
labels: 'uncategorised'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
41
.github/workflows/cli-deno-tests.yml
vendored
41
.github/workflows/cli-deno-tests.yml
vendored
@@ -17,9 +17,48 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
task_matrix: ${{ steps.select.outputs.task_matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Select task matrix
|
||||||
|
id: select
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
||||||
|
echo "[INFO] Selected task set: $SELECTED_TASK"
|
||||||
|
|
||||||
|
case "$SELECTED_TASK" in
|
||||||
|
test)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:push-pull","test:sync-two-local","test:sync-locked-remote","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-couchdb","test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:local)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror"]'
|
||||||
|
;;
|
||||||
|
test:e2e-matrix)
|
||||||
|
TASK_MATRIX='["test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:p2p-sync)
|
||||||
|
TASK_MATRIX='["test:p2p-sync"]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unknown task set: $SELECTED_TASK" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "task_matrix=$TASK_MATRIX" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
task: ${{ fromJson(needs.prepare.outputs.task_matrix) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -64,7 +103,7 @@ jobs:
|
|||||||
LIVESYNC_DOCKER_MODE: native
|
LIVESYNC_DOCKER_MODE: native
|
||||||
LIVESYNC_CLI_RETRY: 3
|
LIVESYNC_CLI_RETRY: 3
|
||||||
run: |
|
run: |
|
||||||
TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
TASK="${{ matrix.task }}"
|
||||||
echo "[INFO] Running Deno task: $TASK"
|
echo "[INFO] Running Deno task: $TASK"
|
||||||
deno task "$TASK"
|
deno task "$TASK"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const prettierConfig = {
|
|||||||
tabWidth: 4,
|
tabWidth: 4,
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
semi: true,
|
semi: true,
|
||||||
endOfLine: "cr",
|
endOfLine: "lf",
|
||||||
...localPrettierConfig,
|
...localPrettierConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
3
devs.md
3
devs.md
@@ -63,6 +63,9 @@ npm test # Run vitest tests (requires Docker services)
|
|||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
|
|
||||||
|
- Clone with submodules: `git clone --recurse-submodules <repository-url>`
|
||||||
|
- If you already cloned without them, run: `git submodule update --init --recursive`
|
||||||
|
- The shared common library is provided by the `src/lib` submodule, and builds will fail if it is missing
|
||||||
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
||||||
- Development builds auto-copy to these paths on build
|
- Development builds auto-copy to these paths on build
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default [
|
|||||||
"modules/octagonal-wheels/rollup.config.js",
|
"modules/octagonal-wheels/rollup.config.js",
|
||||||
"modules/octagonal-wheels/dist/**/*",
|
"modules/octagonal-wheels/dist/**/*",
|
||||||
"src/lib/test",
|
"src/lib/test",
|
||||||
|
"src/lib/_tools",
|
||||||
"src/lib/src/cli",
|
"src/lib/src/cli",
|
||||||
"**/main.js",
|
"**/main.js",
|
||||||
"src/apps/**/*",
|
"src/apps/**/*",
|
||||||
|
|||||||
61
package-lock.json
generated
61
package-lock.json
generated
@@ -16,11 +16,13 @@
|
|||||||
"@smithy/protocol-http": "^5.3.9",
|
"@smithy/protocol-http": "^5.3.9",
|
||||||
"@smithy/querystring-builder": "^4.2.9",
|
"@smithy/querystring-builder": "^4.2.9",
|
||||||
"@trystero-p2p/nostr": "^0.23.0",
|
"@trystero-p2p/nostr": "^0.23.0",
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
|
"micromatch": "^4.0.0",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"octagonal-wheels": "^0.1.45",
|
"octagonal-wheels": "^0.1.45",
|
||||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@types/deno": "^2.5.0",
|
"@types/deno": "^2.5.0",
|
||||||
"@types/diff-match-patch": "^1.0.36",
|
"@types/diff-match-patch": "^1.0.36",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/micromatch": "^4.0.10",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pouchdb": "^6.4.2",
|
"@types/pouchdb": "^6.4.2",
|
||||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||||
@@ -984,7 +987,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -2378,7 +2380,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@minhducsun2002/leb128": {
|
"node_modules/@minhducsun2002/leb128": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -4224,7 +4227,6 @@
|
|||||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
@@ -4298,6 +4300,13 @@
|
|||||||
"@babel/types": "^7.0.0"
|
"@babel/types": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/braces": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -4417,6 +4426,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/micromatch": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/braces": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/minimatch": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
|
||||||
@@ -4738,7 +4757,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -4943,7 +4961,6 @@
|
|||||||
"integrity": "sha512-gjjrFC4+kPVK/fN9URDJWrssU5Gqh8Az8pKG/NSfQ2V+ky8b/y1BgBg0Ug13+hOGp5pzInonmGRPn7vOgSLgzA==",
|
"integrity": "sha512-gjjrFC4+kPVK/fN9URDJWrssU5Gqh8Az8pKG/NSfQ2V+ky8b/y1BgBg0Ug13+hOGp5pzInonmGRPn7vOgSLgzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blazediff/core": "1.9.1",
|
"@blazediff/core": "1.9.1",
|
||||||
"@vitest/mocker": "4.1.1",
|
"@vitest/mocker": "4.1.1",
|
||||||
@@ -4967,7 +4984,6 @@
|
|||||||
"integrity": "sha512-dtVSBZZha2k/7P7EAXXrEAoxuIKl8Yv9f2Dk4GN/DGfmhf4DQvkvu+57okR2wq/gan1xppKjL/aBxK/kbYrbGw==",
|
"integrity": "sha512-dtVSBZZha2k/7P7EAXXrEAoxuIKl8Yv9f2Dk4GN/DGfmhf4DQvkvu+57okR2wq/gan1xppKjL/aBxK/kbYrbGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/browser": "4.1.1",
|
"@vitest/browser": "4.1.1",
|
||||||
"@vitest/mocker": "4.1.1",
|
"@vitest/mocker": "4.1.1",
|
||||||
@@ -5409,7 +5425,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -6123,7 +6138,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -6152,7 +6166,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6385,7 +6398,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
@@ -6648,7 +6660,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
@@ -7441,7 +7454,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -7555,7 +7567,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -8255,7 +8266,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -9358,7 +9368,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
@@ -9695,7 +9704,6 @@
|
|||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -10409,7 +10417,6 @@
|
|||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.3",
|
"braces": "^3.0.3",
|
||||||
@@ -11119,7 +11126,6 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -11203,7 +11209,6 @@
|
|||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.58.2"
|
||||||
},
|
},
|
||||||
@@ -11270,7 +11275,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -11296,7 +11300,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lilconfig": "^3.1.1"
|
"lilconfig": "^3.1.1"
|
||||||
},
|
},
|
||||||
@@ -11943,7 +11946,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
@@ -12956,7 +12958,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/sublevel-pouchdb": {
|
"node_modules/sublevel-pouchdb": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
@@ -13025,7 +13028,6 @@
|
|||||||
"integrity": "sha512-0a/huwc8e2es+7KFi70esqsReRfRbrT8h1cJSY/+z1lF0yKM6TT+//HYu28Yxstr50H7ifaqZRDGd0KuKDxP7w==",
|
"integrity": "sha512-0a/huwc8e2es+7KFi70esqsReRfRbrT8h1cJSY/+z1lF0yKM6TT+//HYu28Yxstr50H7ifaqZRDGd0KuKDxP7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -13336,7 +13338,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -13358,7 +13359,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -13455,7 +13455,6 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -14086,7 +14085,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -14236,7 +14234,6 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -14873,7 +14870,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14907,7 +14903,6 @@
|
|||||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.1",
|
"@vitest/expect": "4.1.1",
|
||||||
"@vitest/mocker": "4.1.1",
|
"@vitest/mocker": "4.1.1",
|
||||||
@@ -15015,7 +15010,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/wait-port": {
|
"node_modules/wait-port": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -15667,7 +15663,6 @@
|
|||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"@types/deno": "^2.5.0",
|
"@types/deno": "^2.5.0",
|
||||||
"@types/diff-match-patch": "^1.0.36",
|
"@types/diff-match-patch": "^1.0.36",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/micromatch": "^4.0.10",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pouchdb": "^6.4.2",
|
"@types/pouchdb": "^6.4.2",
|
||||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||||
@@ -133,11 +134,13 @@
|
|||||||
"@smithy/protocol-http": "^5.3.9",
|
"@smithy/protocol-http": "^5.3.9",
|
||||||
"@smithy/querystring-builder": "^4.2.9",
|
"@smithy/querystring-builder": "^4.2.9",
|
||||||
"@trystero-p2p/nostr": "^0.23.0",
|
"@trystero-p2p/nostr": "^0.23.0",
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
|
"micromatch": "^4.0.0",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"octagonal-wheels": "^0.1.45",
|
"octagonal-wheels": "^0.1.45",
|
||||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
|
|||||||
2
src/apps/cli/.gitignore
vendored
2
src/apps/cli/.gitignore
vendored
@@ -4,3 +4,5 @@ test/*
|
|||||||
test/test-init.local.sh
|
test/test-init.local.sh
|
||||||
node_modules
|
node_modules
|
||||||
.*.json
|
.*.json
|
||||||
|
*.env
|
||||||
|
!.test.env
|
||||||
@@ -95,13 +95,24 @@ livesync-cli ./my-db pull folder/note.md ./note.md
|
|||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
# Clone with submodules, because the shared core lives in src/lib
|
||||||
# due to shared dependencies with webapp and main library
|
git clone --recurse-submodules <repository-url>
|
||||||
|
cd obsidian-livesync
|
||||||
|
|
||||||
|
# If you already cloned without submodules, run this once instead
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
# Install dependencies from the repository root
|
||||||
npm install
|
npm install
|
||||||
# Build the project (ensure you are in `src/apps/cli` directory)
|
|
||||||
|
# Build the CLI from its package directory
|
||||||
|
cd src/apps/cli
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `src/lib` is missing, `npm run build` now stops early with a targeted message
|
||||||
|
instead of a low-level Vite `ENOENT` error.
|
||||||
|
|
||||||
Run the CLI:
|
Run the CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -286,9 +297,11 @@ Options:
|
|||||||
--force, -f Overwrite existing file on init-settings
|
--force, -f Overwrite existing file on init-settings
|
||||||
--verbose, -v Enable verbose logging
|
--verbose, -v Enable verbose logging
|
||||||
--debug, -d Enable debug logging (includes verbose)
|
--debug, -d Enable debug logging (includes verbose)
|
||||||
--help, -h Show help message
|
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||||
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
||||||
sync Run one replication cycle and exit
|
sync Run one replication cycle and exit
|
||||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||||
@@ -395,6 +408,86 @@ In other words, it performs the following actions:
|
|||||||
|
|
||||||
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
||||||
|
|
||||||
|
##### daemon
|
||||||
|
|
||||||
|
`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions:
|
||||||
|
|
||||||
|
- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`).
|
||||||
|
- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB.
|
||||||
|
|
||||||
|
In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage.
|
||||||
|
|
||||||
|
The daemon exits cleanly on `SIGINT` or `SIGTERM`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LiveSync mode (default — _changes feed, near-real-time)
|
||||||
|
livesync-cli /path/to/vault
|
||||||
|
|
||||||
|
# Polling mode — poll every 60 seconds
|
||||||
|
livesync-cli /path/to/vault --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### .livesync/ignore
|
||||||
|
|
||||||
|
Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local).
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
- Lines beginning with `#` are comments.
|
||||||
|
- Blank lines are ignored.
|
||||||
|
- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root.
|
||||||
|
- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules.
|
||||||
|
- Negation patterns (lines starting with `!`) are not supported and will cause an error on load.
|
||||||
|
|
||||||
|
**Example `.livesync/ignore`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Ignore temporary files
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Merge patterns from .gitignore
|
||||||
|
import: .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync.
|
||||||
|
|
||||||
|
Changes to this file require a daemon restart to take effect.
|
||||||
|
|
||||||
|
### Systemd Installation
|
||||||
|
|
||||||
|
The `deploy/` directory contains a systemd unit template and an install script.
|
||||||
|
|
||||||
|
**Automated install (user service, recommended):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
**With polling interval:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Builds the CLI (`npm install` + `npm run build`).
|
||||||
|
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
|
||||||
|
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
|
||||||
|
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
|
||||||
|
|
||||||
|
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
|
||||||
|
|
||||||
### Planned options:
|
### Planned options:
|
||||||
|
|
||||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
|
|
||||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||||
const pathStr = this.normalisePath(p);
|
const pathStr = this.normalisePath(p);
|
||||||
|
|
||||||
const cached = this.fileCache.get(pathStr);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.refreshFile(pathStr);
|
return await this.refreshFile(pathStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,14 +98,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
path: pathStr as FilePath,
|
path: pathStr as FilePath,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.fileCache.set(pathStr, file);
|
this.fileCache.set(pathStr, file);
|
||||||
return file;
|
return file;
|
||||||
} catch {
|
} catch {
|
||||||
|
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||||
this.fileCache.delete(pathStr);
|
this.fileCache.delete(pathStr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -137,8 +132,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
path: entryRelativePath as FilePath,
|
path: entryRelativePath as FilePath,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
const stat = await fs.stat(this.resolvePath(p));
|
const stat = await fs.stat(this.resolvePath(p));
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: stat.isDirectory() ? "folder" : "file",
|
type: stat.isDirectory() ? "folder" : "file",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(file: NodeFile): Promise<string> {
|
async read(file: NodeFile): Promise<string> {
|
||||||
return await fs.readFile(this.resolvePath(file.path), "utf-8");
|
const content = await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||||
|
// Correct stale stat.size — chokidar stats may be from a poll before the final write.
|
||||||
|
// The downstream document integrity check compares stat.size to content length, so
|
||||||
|
// they must agree or other clients reject the file as corrupted.
|
||||||
|
file.stat.size = Buffer.byteLength(content, "utf-8");
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cachedRead(file: NodeFile): Promise<string> {
|
async cachedRead(file: NodeFile): Promise<string> {
|
||||||
@@ -25,6 +30,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
|
|
||||||
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
||||||
const buffer = await fs.readFile(this.resolvePath(file.path));
|
const buffer = await fs.readFile(this.resolvePath(file.path));
|
||||||
|
// Same correction as read() — ensure stat.size matches actual byte length.
|
||||||
|
file.stat.size = buffer.length;
|
||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +73,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
path: p as any,
|
path: p as any,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -89,8 +96,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
path: p as any,
|
path: p as any,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { runCommand } from "./runCommand";
|
||||||
|
import type { CLIOptions } from "./types";
|
||||||
|
|
||||||
|
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
|
||||||
|
vi.mock("@lib/serviceFeatures/offlineScanner", () => ({
|
||||||
|
performFullScan: vi.fn(async () => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock UnresolvedErrorManager to avoid event-hub side effects.
|
||||||
|
vi.mock("@lib/services/base/UnresolvedErrorManager", () => ({
|
||||||
|
UnresolvedErrorManager: class UnresolvedErrorManager {
|
||||||
|
showError() {}
|
||||||
|
clearError() {}
|
||||||
|
clearErrors() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as offlineScanner from "@lib/serviceFeatures/offlineScanner";
|
||||||
|
|
||||||
|
function createCoreMock() {
|
||||||
|
return {
|
||||||
|
services: {
|
||||||
|
control: {
|
||||||
|
activated: Promise.resolve(),
|
||||||
|
applySettings: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
applyPartial: vi.fn(async () => {}),
|
||||||
|
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||||
|
},
|
||||||
|
replication: {
|
||||||
|
replicate: vi.fn(async () => true),
|
||||||
|
},
|
||||||
|
appLifecycle: {
|
||||||
|
onUnload: {
|
||||||
|
addHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceModules: {
|
||||||
|
fileHandler: {
|
||||||
|
dbToStorage: vi.fn(async () => true),
|
||||||
|
storeFileToDB: vi.fn(async () => true),
|
||||||
|
},
|
||||||
|
storageAccess: {
|
||||||
|
readFileAuto: vi.fn(async () => ""),
|
||||||
|
writeFileAuto: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
databaseFileAccess: {
|
||||||
|
fetch: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDaemonOptions(interval?: number): CLIOptions {
|
||||||
|
return {
|
||||||
|
command: "daemon",
|
||||||
|
commandArgs: [],
|
||||||
|
databasePath: "/tmp/vault",
|
||||||
|
verbose: false,
|
||||||
|
force: false,
|
||||||
|
interval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseContext = {
|
||||||
|
vaultPath: "/tmp/vault",
|
||||||
|
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||||
|
originalSyncSettings: {
|
||||||
|
liveSync: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
periodicReplication: false,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnEditorSave: false,
|
||||||
|
syncOnFileOpen: false,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
describe("daemon command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls performFullScan during startup", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when performFullScan fails", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: calls setTimeout when interval option is set", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// Interval should be in milliseconds (30s → 30000ms)
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ suspendFileWatching: false }),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: calls applyPartial and applySettings", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
...baseContext.originalSyncSettings,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: logs warning when both liveSync and syncOnStart are false", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.setting.currentSettings = vi.fn(() => ({
|
||||||
|
liveSync: false,
|
||||||
|
syncOnStart: false,
|
||||||
|
}));
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
const warningCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||||
|
);
|
||||||
|
expect(warningCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: no warning when liveSync is true", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.setting.currentSettings = vi.fn(() => ({
|
||||||
|
liveSync: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
}));
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
const warningCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||||
|
);
|
||||||
|
expect(warningCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls replicate before performFullScan", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callOrder.push("replicate");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||||
|
callOrder.push("performFullScan");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when initial replication fails", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.replication.replicate = vi.fn(async () => false);
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
// performFullScan should NOT have been called
|
||||||
|
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||||
|
|
||||||
|
// onUnload handler should have been registered
|
||||||
|
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||||
|
const handler = core.services.appLifecycle.onUnload.addHandler.mock.calls[0][0];
|
||||||
|
|
||||||
|
// Get the timeout ID that was created
|
||||||
|
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||||
|
await handler();
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||||
|
let callCount = 0;
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return true; // initial startup replicate
|
||||||
|
if (callCount <= 7) throw new Error("network failure");
|
||||||
|
return true; // recovery
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMs = 30 * 1000;
|
||||||
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
// After runCommand returns the first setTimeout has been scheduled.
|
||||||
|
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||||
|
expect(setTimeoutSpy.mock.calls[0][1]).toBe(baseMs);
|
||||||
|
|
||||||
|
// Advance through 6 failure polls. After each failure the next setTimeout
|
||||||
|
// should be scheduled with a larger (or capped) interval.
|
||||||
|
// formula: min(base * 2^n, 300000). base=30000ms.
|
||||||
|
// failure 1: 30000*2=60000, failure 2: 30000*4=120000,
|
||||||
|
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
|
||||||
|
const expectedIntervals = [
|
||||||
|
baseMs * 2, // after failure 1: 60000
|
||||||
|
baseMs * 4, // after failure 2: 120000
|
||||||
|
baseMs * 8, // after failure 3: 240000
|
||||||
|
300_000, // after failure 4 (would be 480000, capped)
|
||||||
|
300_000, // after failure 5 (cap)
|
||||||
|
300_000, // after failure 6 (cap)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const expected of expectedIntervals) {
|
||||||
|
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||||
|
const newCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
expect(newCallCount).toBeGreaterThan(prevCallCount);
|
||||||
|
expect(setTimeoutSpy.mock.calls[newCallCount - 1][1]).toBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now trigger the success poll — interval should halve each time toward base.
|
||||||
|
// After failure 6, consecutiveFailures=6, currentIntervalMs=300000.
|
||||||
|
// On success: consecutiveFailures=5, currentIntervalMs=150000.
|
||||||
|
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||||
|
const afterSuccessCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
expect(afterSuccessCallCount).toBeGreaterThan(prevCallCount);
|
||||||
|
// The interval after one success should be halved (300000 / 2 = 150000).
|
||||||
|
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling error handling: replicate rejection is caught and console.error is called", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||||
|
let callCount = 0;
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return true; // startup replicate
|
||||||
|
throw new Error("network failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervalMs = 30 * 1000;
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
// Advance time to trigger the first poll callback and flush its async work.
|
||||||
|
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||||
|
|
||||||
|
// No unhandled rejection — the error was caught internally.
|
||||||
|
const errorCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("Poll error")
|
||||||
|
);
|
||||||
|
expect(errorCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,96 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
|
|
||||||
await core.services.control.activated;
|
await core.services.control.activated;
|
||||||
if (options.command === "daemon") {
|
if (options.command === "daemon") {
|
||||||
|
const log = (msg: unknown) => console.error(`[Daemon] ${msg}`);
|
||||||
|
|
||||||
|
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||||
|
// and the default "Dismiss" action would block replication. The daemon should
|
||||||
|
// accept whatever configuration the remote has.
|
||||||
|
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||||
|
|
||||||
|
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||||
|
log("Replicating from CouchDB...");
|
||||||
|
const replResult = await core.services.replication.replicate(true);
|
||||||
|
if (!replResult) {
|
||||||
|
console.error("[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log("CouchDB replication complete");
|
||||||
|
|
||||||
|
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||||
|
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle);
|
||||||
|
log("Running mirror scan...");
|
||||||
|
const scanOk = await performFullScan(core as any, log, errorManager, false, true);
|
||||||
|
if (!scanOk) {
|
||||||
|
console.error("[Daemon] Mirror scan failed, cannot continue");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log("Mirror scan complete");
|
||||||
|
|
||||||
|
// 3. Re-enable sync.
|
||||||
|
const restoreSyncSettings = async () => {
|
||||||
|
await core.services.setting.applyPartial({
|
||||||
|
...context.originalSyncSettings,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
}, true);
|
||||||
|
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||||
|
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||||
|
// via fireAndForget.
|
||||||
|
await core.services.control.applySettings();
|
||||||
|
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||||
|
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||||
|
// with true is a direct store write — it does not re-trigger lifecycle.
|
||||||
|
await core.services.setting.applyPartial({
|
||||||
|
suspendFileWatching: false,
|
||||||
|
suspendParseReplicationResult: false,
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
if (options.interval) {
|
||||||
|
log(`Polling mode: syncing every ${options.interval}s`);
|
||||||
|
await restoreSyncSettings();
|
||||||
|
const baseIntervalMs = options.interval * 1000;
|
||||||
|
let currentIntervalMs = baseIntervalMs;
|
||||||
|
let consecutiveFailures = 0;
|
||||||
|
const maxIntervalMs = 5 * 60 * 1000; // 5 minutes cap
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
await core.services.replication.replicate(true);
|
||||||
|
if (consecutiveFailures > 0) {
|
||||||
|
consecutiveFailures--;
|
||||||
|
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||||
|
log(`Replication recovered`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
consecutiveFailures++;
|
||||||
|
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||||
|
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
||||||
|
if (consecutiveFailures >= 5) {
|
||||||
|
console.error(`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||||
|
};
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> = setTimeout(poll, currentIntervalMs);
|
||||||
|
core.services.appLifecycle.onUnload.addHandler(async () => {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||||
|
await restoreSyncSettings();
|
||||||
|
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||||
|
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||||
|
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||||
|
log("LiveSync active");
|
||||||
|
const currentSettings = core.services.setting.currentSettings();
|
||||||
|
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||||
|
console.error("[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
||||||
|
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
||||||
|
"or use --interval for polling mode.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +173,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
||||||
|
|
||||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||||
mtime: sourceStat.mtimeMs,
|
mtime: Math.floor(sourceStat.mtimeMs),
|
||||||
ctime: sourceStat.ctimeMs,
|
ctime: Math.floor(sourceStat.ctimeMs),
|
||||||
});
|
});
|
||||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||||
|
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||||
|
|
||||||
export type CLICommand =
|
export type CLICommand =
|
||||||
| "daemon"
|
| "daemon"
|
||||||
@@ -29,15 +30,18 @@ export interface CLIOptions {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
command: CLICommand;
|
command: CLICommand;
|
||||||
commandArgs: string[];
|
commandArgs: string[];
|
||||||
|
interval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CLICommandContext {
|
export interface CLICommandContext {
|
||||||
databasePath: string;
|
databasePath: string;
|
||||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
|
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VALID_COMMANDS = new Set([
|
export const VALID_COMMANDS = new Set([
|
||||||
|
"daemon",
|
||||||
"sync",
|
"sync",
|
||||||
"p2p-peers",
|
"p2p-peers",
|
||||||
"p2p-sync",
|
"p2p-sync",
|
||||||
|
|||||||
187
src/apps/cli/deploy/install.sh
Executable file
187
src/apps/cli/deploy/install.sh
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — install livesync-cli as a systemd service
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# install.sh [--user] [--system] [--vault <path>] [--interval <N>]
|
||||||
|
#
|
||||||
|
# Defaults: user install, prompts for vault path if not supplied.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
CLI_DIR="$REPO_ROOT/src/apps/cli"
|
||||||
|
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
|
||||||
|
|
||||||
|
# ── Argument parsing ────────────────────────────────────────────────────────
|
||||||
|
INSTALL_MODE="user"
|
||||||
|
VAULT_PATH=""
|
||||||
|
INTERVAL=""
|
||||||
|
FORCE=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--user)
|
||||||
|
INSTALL_MODE="user"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--system)
|
||||||
|
INSTALL_MODE="system"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--vault)
|
||||||
|
if [[ -z "${2:-}" ]]; then
|
||||||
|
echo "Error: --vault requires a path argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VAULT_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--interval)
|
||||||
|
if [[ -z "${2:-}" ]]; then
|
||||||
|
echo "Error: --interval requires a numeric argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
INTERVAL="$2"
|
||||||
|
if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "Error: --interval requires a positive integer, got '$INTERVAL'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force|-f)
|
||||||
|
FORCE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat <<EOF
|
||||||
|
Usage: install.sh [--user|--system] [--vault <path>] [--interval <N>] [--force]
|
||||||
|
|
||||||
|
--user Install as a user systemd service (default, ~/.config/systemd/user/)
|
||||||
|
--system Install as a system systemd service (/etc/systemd/system/)
|
||||||
|
--vault Path to the vault directory (prompted if omitted)
|
||||||
|
--interval Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
--force Overwrite existing service unit without prompting
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Vault path ──────────────────────────────────────────────────────────────
|
||||||
|
if [[ -z "$VAULT_PATH" ]]; then
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Error: --vault is required in non-interactive mode" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'Vault path: '
|
||||||
|
read -r VAULT_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
|
_orig_vault="$VAULT_PATH"
|
||||||
|
if ! VAULT_PATH="$(cd -- "$VAULT_PATH" 2>/dev/null && pwd)"; then
|
||||||
|
echo "Error: vault directory does not exist: $_orig_vault" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Vault: $VAULT_PATH"
|
||||||
|
echo "[INFO] Install mode: $INSTALL_MODE"
|
||||||
|
|
||||||
|
# ── Build ────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[INFO] Building CLI from $REPO_ROOT..."
|
||||||
|
(cd "$REPO_ROOT" && npm install --silent)
|
||||||
|
(cd "$CLI_DIR" && npm run build)
|
||||||
|
|
||||||
|
BUILT_CJS="$CLI_DIR/dist/index.cjs"
|
||||||
|
if [[ ! -f "$BUILT_CJS" ]]; then
|
||||||
|
echo "Error: build output not found: $BUILT_CJS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install binary ───────────────────────────────────────────────────────────
|
||||||
|
if [[ "$INSTALL_MODE" == "user" ]]; then
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
|
SYSTEMCTL_FLAGS="--user"
|
||||||
|
else
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
UNIT_DIR="/etc/systemd/system"
|
||||||
|
SYSTEMCTL_FLAGS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
|
||||||
|
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
|
||||||
|
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
|
||||||
|
|
||||||
|
# Copy the CJS bundle so the wrapper is self-contained and independent of the
|
||||||
|
# build directory location.
|
||||||
|
cp "$BUILT_CJS" "$LIVESYNC_JS"
|
||||||
|
|
||||||
|
# Write a bash wrapper that invokes node on the installed bundle.
|
||||||
|
cat > "$LIVESYNC_BIN" <<WRAPPER
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec node "$LIVESYNC_JS" "\$@"
|
||||||
|
WRAPPER
|
||||||
|
chmod +x "$LIVESYNC_BIN"
|
||||||
|
echo "[INFO] Installed bundle: $LIVESYNC_JS"
|
||||||
|
echo "[INFO] Installed binary: $LIVESYNC_BIN"
|
||||||
|
|
||||||
|
# ── Write systemd unit ───────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
UNIT_PATH="$UNIT_DIR/livesync-cli.service"
|
||||||
|
|
||||||
|
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\""
|
||||||
|
if [[ -n "$INTERVAL" ]]; then
|
||||||
|
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\" --interval $INTERVAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for existing service and offer to overwrite.
|
||||||
|
if [[ -f "$UNIT_PATH" ]] && [[ "$FORCE" -eq 0 ]]; then
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Error: service unit already exists at $UNIT_PATH; use --force to overwrite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'Service unit already exists at %s. Overwrite? [y/N]: ' "$UNIT_PATH"
|
||||||
|
read -r CONFIRM
|
||||||
|
case "$CONFIRM" in
|
||||||
|
[yY]|[yY][eE][sS]) : ;;
|
||||||
|
*)
|
||||||
|
echo "[INFO] Aborted. Existing unit left in place."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In awk gsub(), '&' in the replacement means "matched text"; escape any literal '&'
|
||||||
|
# in path variables before passing them as awk replacement strings.
|
||||||
|
AWK_BIN="${LIVESYNC_BIN//&/\\&}"
|
||||||
|
AWK_VAULT="${VAULT_PATH//&/\\&}"
|
||||||
|
awk -v bin="$AWK_BIN" -v vault="$AWK_VAULT" -v exec_start="ExecStart=$EXEC_START" \
|
||||||
|
'/^ExecStart=/ { print exec_start; next } {gsub("LIVESYNC_BIN", bin); gsub("LIVESYNC_VAULT_PATH", vault); print}' \
|
||||||
|
"$SERVICE_TEMPLATE" > "$UNIT_PATH"
|
||||||
|
|
||||||
|
echo "[INFO] Installed unit: $UNIT_PATH"
|
||||||
|
|
||||||
|
# ── Enable service ───────────────────────────────────────────────────────────
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
|
echo "[WARN] systemctl not found — skipping service activation"
|
||||||
|
echo "[INFO] To enable manually, copy $UNIT_PATH to the correct systemd directory and run:"
|
||||||
|
echo " systemctl $SYSTEMCTL_FLAGS daemon-reload"
|
||||||
|
echo " systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS daemon-reload
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[Done] livesync-cli service installed and started."
|
||||||
|
echo ""
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
|
||||||
17
src/apps/cli/deploy/livesync-cli.service
Normal file
17
src/apps/cli/deploy/livesync-cli.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Self-hosted LiveSync CLI Daemon
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStartSec=300
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -26,6 +26,7 @@ import { VALID_COMMANDS } from "./commands/types";
|
|||||||
import type { CLICommand, CLIOptions } from "./commands/types";
|
import type { CLICommand, CLIOptions } from "./commands/types";
|
||||||
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||||
|
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||||
|
|
||||||
const SETTINGS_FILE = ".livesync/settings.json";
|
const SETTINGS_FILE = ".livesync/settings.json";
|
||||||
ensureGlobalNodeLocalStorage();
|
ensureGlobalNodeLocalStorage();
|
||||||
@@ -43,6 +44,7 @@ Arguments:
|
|||||||
database-path Path to the local database directory
|
database-path Path to the local database directory
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||||
sync Run one replication cycle and exit
|
sync Run one replication cycle and exit
|
||||||
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
||||||
p2p-sync <peer> <timeout>
|
p2p-sync <peer> <timeout>
|
||||||
@@ -60,7 +62,13 @@ Commands:
|
|||||||
rm <path> Mark a file as deleted in local database
|
rm <path> Mark a file as deleted in local database
|
||||||
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
|
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
|
||||||
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
|
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||||
|
livesync-cli ./my-database --interval 30 Run daemon (polling every 30s)
|
||||||
livesync-cli ./my-database sync
|
livesync-cli ./my-database sync
|
||||||
livesync-cli ./my-database p2p-peers 5
|
livesync-cli ./my-database p2p-peers 5
|
||||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||||
@@ -94,6 +102,7 @@ export function parseArgs(): CLIOptions {
|
|||||||
let verbose = false;
|
let verbose = false;
|
||||||
let debug = false;
|
let debug = false;
|
||||||
let force = false;
|
let force = false;
|
||||||
|
let interval: number | undefined;
|
||||||
let command: CLICommand = "daemon";
|
let command: CLICommand = "daemon";
|
||||||
const commandArgs: string[] = [];
|
const commandArgs: string[] = [];
|
||||||
|
|
||||||
@@ -110,6 +119,21 @@ export function parseArgs(): CLIOptions {
|
|||||||
settingsPath = args[i];
|
settingsPath = args[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "--interval":
|
||||||
|
case "-i": {
|
||||||
|
i++;
|
||||||
|
if (!args[i]) {
|
||||||
|
console.error(`Error: Missing value for ${token}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const n = parseInt(args[i], 10);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) {
|
||||||
|
console.error(`Error: --interval requires a positive integer, got '${args[i]}'`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
interval = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "--debug":
|
case "--debug":
|
||||||
case "-d":
|
case "-d":
|
||||||
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
||||||
@@ -164,6 +188,7 @@ export function parseArgs(): CLIOptions {
|
|||||||
force,
|
force,
|
||||||
command,
|
command,
|
||||||
commandArgs,
|
commandArgs,
|
||||||
|
interval,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +222,9 @@ async function createDefaultSettingsFile(options: CLIOptions) {
|
|||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
const options = parseArgs();
|
const options = parseArgs();
|
||||||
|
if (options.interval && options.command !== "daemon") {
|
||||||
|
console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||||
|
}
|
||||||
const avoidStdoutNoise =
|
const avoidStdoutNoise =
|
||||||
options.command === "cat" ||
|
options.command === "cat" ||
|
||||||
options.command === "cat-rev" ||
|
options.command === "cat-rev" ||
|
||||||
@@ -248,6 +276,20 @@ export async function main() {
|
|||||||
infoLog(`Settings: ${settingsPath}`);
|
infoLog(`Settings: ${settingsPath}`);
|
||||||
infoLog("");
|
infoLog("");
|
||||||
|
|
||||||
|
// For daemon and mirror mode, load ignore rules before the core is constructed so that
|
||||||
|
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
||||||
|
const watchEnabled = options.command === "daemon";
|
||||||
|
const vaultPath =
|
||||||
|
options.command === "mirror" && options.commandArgs[0]
|
||||||
|
? path.resolve(options.commandArgs[0])
|
||||||
|
: databasePath;
|
||||||
|
let ignoreRules: IgnoreRules | undefined;
|
||||||
|
if (options.command === "daemon" || options.command === "mirror") {
|
||||||
|
ignoreRules = new IgnoreRules(vaultPath);
|
||||||
|
await ignoreRules.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create service context and hub
|
// Create service context and hub
|
||||||
const context = new NodeServiceContext(databasePath);
|
const context = new NodeServiceContext(databasePath);
|
||||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||||
@@ -278,11 +320,14 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
console.error(`${prefix} ${message}`);
|
console.error(`${prefix} ${message}`);
|
||||||
});
|
});
|
||||||
// Prevent replication result to be processed automatically.
|
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||||
|
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||||
|
if (options.command !== "daemon") {
|
||||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||||
return await Promise.resolve(true);
|
return await Promise.resolve(true);
|
||||||
}, -100);
|
}, -100);
|
||||||
|
}
|
||||||
|
|
||||||
// Setup settings handlers
|
// Setup settings handlers
|
||||||
const settingService = serviceHubInstance.setting;
|
const settingService = serviceHubInstance.setting;
|
||||||
@@ -324,11 +369,7 @@ export async function main() {
|
|||||||
const core = new LiveSyncBaseCore(
|
const core = new LiveSyncBaseCore(
|
||||||
serviceHubInstance,
|
serviceHubInstance,
|
||||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||||
const mirrorVaultPath =
|
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||||
options.command === "mirror" && options.commandArgs[0]
|
|
||||||
? path.resolve(options.commandArgs[0])
|
|
||||||
: databasePath;
|
|
||||||
return initialiseServiceModulesCLI(mirrorVaultPath, core, serviceHub);
|
|
||||||
},
|
},
|
||||||
(core) => [
|
(core) => [
|
||||||
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
||||||
@@ -344,8 +385,25 @@ export async function main() {
|
|||||||
if (parts.some((part) => part.startsWith("."))) {
|
if (parts.some((part) => part.startsWith("."))) {
|
||||||
return await Promise.resolve(false);
|
return await Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
// PouchDB LevelDB database directory lives in the vault directory.
|
||||||
|
if (parts[0]?.endsWith("-livesync-v2")) {
|
||||||
|
return await Promise.resolve(false);
|
||||||
|
}
|
||||||
return await Promise.resolve(true);
|
return await Promise.resolve(true);
|
||||||
}, -1 /* highest priority */);
|
}, -1 /* highest priority */);
|
||||||
|
|
||||||
|
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
|
||||||
|
if (ignoreRules) {
|
||||||
|
const rules = ignoreRules;
|
||||||
|
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||||
|
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||||
|
if (rules.shouldIgnore(targetPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// undefined = pass through to next handler in chain
|
||||||
|
return undefined;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -366,6 +424,25 @@ export async function main() {
|
|||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||||
|
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||||
|
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||||
|
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
||||||
|
|
||||||
|
// Restore settings file on any exit to undo lifecycle mutations.
|
||||||
|
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||||
|
process.on("exit", () => {
|
||||||
|
if (settingsBackup) {
|
||||||
|
const tmpPath = settingsPath + ".tmp";
|
||||||
|
try {
|
||||||
|
require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||||
|
require("fs").renameSync(tmpPath, settingsPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Settings] Failed to restore settings on exit:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start the core
|
// Start the core
|
||||||
try {
|
try {
|
||||||
infoLog(`[Starting] Initializing LiveSync...`);
|
infoLog(`[Starting] Initializing LiveSync...`);
|
||||||
@@ -375,6 +452,18 @@ export async function main() {
|
|||||||
console.error(`[Error] Failed to initialize LiveSync`);
|
console.error(`[Error] Failed to initialize LiveSync`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
// Capture sync settings before suspendAllSync() clobbers them.
|
||||||
|
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||||
|
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||||
|
const originalSyncSettings = {
|
||||||
|
liveSync: settingsBeforeSuspend.liveSync,
|
||||||
|
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||||
|
periodicReplication: settingsBeforeSuspend.periodicReplication,
|
||||||
|
syncOnSave: settingsBeforeSuspend.syncOnSave,
|
||||||
|
syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave,
|
||||||
|
syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen,
|
||||||
|
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||||
|
};
|
||||||
await core.services.setting.suspendAllSync();
|
await core.services.setting.suspendAllSync();
|
||||||
await core.services.control.onReady();
|
await core.services.control.onReady();
|
||||||
|
|
||||||
@@ -400,7 +489,7 @@ export async function main() {
|
|||||||
infoLog("");
|
infoLog("");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runCommand(options, { databasePath, core, settingsPath });
|
const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.error(`[Error] Command '${options.command}' failed`);
|
console.error(`[Error] Command '${options.command}' failed`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -408,7 +497,7 @@ export async function main() {
|
|||||||
infoLog(`[Done] Command '${options.command}' completed`);
|
infoLog(`[Done] Command '${options.command}' completed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.command === "daemon") {
|
if (options.command === "daemon" && result) {
|
||||||
// Keep the process running
|
// Keep the process running
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -85,4 +85,67 @@ describe("CLI parseArgs", () => {
|
|||||||
expect(parsed.command).toBe("p2p-host");
|
expect(parsed.command).toBe("p2p-host");
|
||||||
expect(parsed.commandArgs).toEqual([]);
|
expect(parsed.commandArgs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses --interval flag with valid integer", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.interval).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses -i shorthand for --interval", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "-i", "10"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.interval).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval has no value", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
expect(exitMock).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is not a positive integer", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
expect(exitMock).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is negative", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is not numeric", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit daemon command", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "daemon"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.databasePath).toBe("./vault");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to daemon when no command specified", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit daemon command with --interval", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "daemon", "--interval", "30"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.interval).toBe(30);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import type {
|
|||||||
} from "@lib/managers/adapters";
|
} from "@lib/managers/adapters";
|
||||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||||
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
||||||
|
import type { Stats } from "fs";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||||
|
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific type guard adapter
|
* CLI-specific type guard adapter
|
||||||
@@ -56,22 +59,11 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific status adapter (console logging)
|
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
||||||
*/
|
*/
|
||||||
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
||||||
private lastUpdate = 0;
|
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
||||||
private updateInterval = 5000; // Update every 5 seconds
|
// intentional no-op
|
||||||
|
|
||||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastUpdate > this.updateInterval) {
|
|
||||||
if (status.totalQueued > 0 || status.processing > 0) {
|
|
||||||
// console.log(
|
|
||||||
// `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
this.lastUpdate = now;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +92,97 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific watch adapter (optional file watching with chokidar)
|
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||||
*/
|
*/
|
||||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||||
constructor(private basePath: string) {}
|
private _watcher: FSWatcher | undefined;
|
||||||
|
|
||||||
|
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {}
|
||||||
|
|
||||||
|
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||||
|
return {
|
||||||
|
path: path.relative(this.basePath, filePath) as FilePath,
|
||||||
|
stat: {
|
||||||
|
ctime: stats?.ctimeMs ?? Date.now(),
|
||||||
|
mtime: stats?.mtimeMs ?? Date.now(),
|
||||||
|
size: stats?.size ?? 0,
|
||||||
|
type: "file",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||||
|
return {
|
||||||
|
path: path.relative(this.basePath, dirPath) as FilePath,
|
||||||
|
isFolder: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||||
// File watching is not activated in the CLI.
|
if (!this.watchEnabled) return;
|
||||||
// Because the CLI is designed for push/pull operations, not real-time sync.
|
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
|
||||||
// console.error("[CLIWatchAdapter] File watching is not enabled in CLI version");
|
/(^|[/\\])\./,
|
||||||
|
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
|
||||||
|
];
|
||||||
|
// Bind rules to a local const before the closure — chokidar v4 requires a
|
||||||
|
// MatchFunction, not glob strings, for custom patterns.
|
||||||
|
const rules = this.ignoreRules;
|
||||||
|
const ignored = rules
|
||||||
|
? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))]
|
||||||
|
: baseIgnored;
|
||||||
|
|
||||||
|
const watcher = chokidarWatch(this.basePath, {
|
||||||
|
ignored,
|
||||||
|
ignoreInitial: true,
|
||||||
|
persistent: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 500,
|
||||||
|
pollInterval: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("add", (filePath, stats) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, stats);
|
||||||
|
handlers.onCreate(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("change", (filePath, stats) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, stats);
|
||||||
|
handlers.onChange(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("unlink", (filePath) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, undefined);
|
||||||
|
handlers.onDelete(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("addDir", (dirPath) => {
|
||||||
|
const nodeFolder = this._toNodeFolder(dirPath);
|
||||||
|
handlers.onCreate(nodeFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("unlinkDir", (dirPath) => {
|
||||||
|
const nodeFolder = this._toNodeFolder(dirPath);
|
||||||
|
handlers.onDelete(nodeFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("error", (err) => {
|
||||||
|
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
||||||
|
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
||||||
|
void watcher.close();
|
||||||
|
this._watcher = undefined;
|
||||||
|
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||||
|
this._watcher = watcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
if (this._watcher) {
|
||||||
|
return this._watcher.close();
|
||||||
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,11 +197,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
|||||||
readonly status: CLIStatusAdapter;
|
readonly status: CLIStatusAdapter;
|
||||||
readonly converter: CLIConverterAdapter;
|
readonly converter: CLIConverterAdapter;
|
||||||
|
|
||||||
constructor(basePath: string) {
|
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
|
||||||
this.typeGuard = new CLITypeGuardAdapter();
|
this.typeGuard = new CLITypeGuardAdapter();
|
||||||
this.persistence = new CLIPersistenceAdapter(basePath);
|
this.persistence = new CLIPersistenceAdapter(basePath);
|
||||||
this.watch = new CLIWatchAdapter(basePath);
|
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
|
||||||
this.status = new CLIStatusAdapter();
|
this.status = new CLIStatusAdapter();
|
||||||
this.converter = new CLIConverterAdapter();
|
this.converter = new CLIConverterAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.watch.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
126
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import type { IStorageEventWatchHandlers } from "@lib/managers/adapters";
|
||||||
|
import type { NodeFile } from "../adapters/NodeTypes";
|
||||||
|
|
||||||
|
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||||
|
// Must be hoisted before imports that pull in chokidar.
|
||||||
|
|
||||||
|
const mockWatcher = {
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
once: vi.fn((event: string, cb: () => void) => {
|
||||||
|
if (event === "ready") cb();
|
||||||
|
return mockWatcher;
|
||||||
|
}),
|
||||||
|
close: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("chokidar", () => ({
|
||||||
|
watch: vi.fn(() => mockWatcher),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as chokidar from "chokidar";
|
||||||
|
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeHandlers(): IStorageEventWatchHandlers {
|
||||||
|
return {
|
||||||
|
onCreate: vi.fn(),
|
||||||
|
onChange: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onRename: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CLIStorageEventManagerAdapter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Restore the default once() behaviour (ready fires synchronously).
|
||||||
|
mockWatcher.once.mockImplementation((event: string, cb: () => void) => {
|
||||||
|
if (event === "ready") cb();
|
||||||
|
return mockWatcher;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
expect(chokidar.watch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beginWatch calls chokidar.watch when watchEnabled=true", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chokidar.watch).toHaveBeenCalledWith(
|
||||||
|
"/base",
|
||||||
|
expect.objectContaining({ ignoreInitial: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||||
|
const basePath = "/vault/base";
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
// Find the callback registered for the "add" event.
|
||||||
|
const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add");
|
||||||
|
expect(addCall).toBeDefined();
|
||||||
|
const addCallback = addCall![1] as (filePath: string, stats: any) => void;
|
||||||
|
|
||||||
|
const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 };
|
||||||
|
addCallback(`${basePath}/subdir/note.md`, fakeStats);
|
||||||
|
|
||||||
|
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
|
||||||
|
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||||
|
expect(created.path).toBe("subdir/note.md");
|
||||||
|
expect(created.stat?.size).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() calls watcher.close()", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
await adapter.close();
|
||||||
|
|
||||||
|
expect(mockWatcher.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() is safe when no watcher was started", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||||
|
|
||||||
|
// Should not throw.
|
||||||
|
await expect(adapter.close()).resolves.toBeUndefined();
|
||||||
|
expect(mockWatcher.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error event triggers process.exit(1)", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||||
|
|
||||||
|
const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error");
|
||||||
|
expect(errorCall).toBeDefined();
|
||||||
|
const errorCallback = errorCall![1] as (err: Error) => void;
|
||||||
|
|
||||||
|
errorCallback(new Error("disk failure"));
|
||||||
|
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } fro
|
|||||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||||
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||||
|
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||||
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
||||||
|
|
||||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||||
@@ -10,9 +11,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
|||||||
constructor(
|
constructor(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||||
dependencies: StorageEventManagerBaseDependencies
|
dependencies: StorageEventManagerBaseDependencies,
|
||||||
|
ignoreRules?: IgnoreRules,
|
||||||
|
watchEnabled?: boolean
|
||||||
) {
|
) {
|
||||||
const adapter = new CLIStorageEventManagerAdapter(basePath);
|
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled);
|
||||||
super(adapter, dependencies);
|
super(adapter, dependencies);
|
||||||
this.core = core;
|
this.core = core;
|
||||||
}
|
}
|
||||||
@@ -25,4 +28,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
|||||||
// No-op in CLI version
|
// No-op in CLI version
|
||||||
// Internal file handling is not needed
|
// Internal file handling is not needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the file watcher. Call this during graceful shutdown.
|
||||||
|
*/
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.adapter.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/check-submodule.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cli": "node dist/index.cjs",
|
"cli": "node dist/index.cjs",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"werift": "^0.22.9",
|
"werift": "^0.22.9",
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
|
|||||||
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const cliDir = process.cwd();
|
||||||
|
const repoRoot = path.resolve(cliDir, "../../..");
|
||||||
|
const requiredFiles = [
|
||||||
|
path.join(repoRoot, "src/lib/src/common/types.ts"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingFiles = requiredFiles.filter((filePath) => !fs.existsSync(filePath));
|
||||||
|
|
||||||
|
if (missingFiles.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[CLI Build Error] Required shared sources were not found.");
|
||||||
|
console.error("This repository uses Git submodules, and the CLI depends on src/lib.");
|
||||||
|
console.error("");
|
||||||
|
console.error("Missing file(s):");
|
||||||
|
for (const filePath of missingFiles) {
|
||||||
|
console.error(` - ${path.relative(repoRoot, filePath)}`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error("Initialize submodules, then retry the CLI build:");
|
||||||
|
console.error(" git submodule update --init --recursive");
|
||||||
|
console.error("");
|
||||||
|
console.error("For a fresh clone, prefer:");
|
||||||
|
console.error(" git clone --recurse-submodules <repository-url>");
|
||||||
|
console.error("");
|
||||||
|
console.error("Then run:");
|
||||||
|
console.error(" npm install");
|
||||||
|
console.error(" cd src/apps/cli");
|
||||||
|
console.error(" npm run build");
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
@@ -9,6 +9,7 @@ import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl";
|
|||||||
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||||
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
||||||
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||||
|
import type { IgnoreRules } from "./IgnoreRules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize service modules for CLI version
|
* Initialize service modules for CLI version
|
||||||
@@ -22,7 +23,9 @@ import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
|||||||
export function initialiseServiceModulesCLI(
|
export function initialiseServiceModulesCLI(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||||
services: InjectableServiceHub<ServiceContext>
|
services: InjectableServiceHub<ServiceContext>,
|
||||||
|
ignoreRules?: IgnoreRules,
|
||||||
|
watchEnabled: boolean = false,
|
||||||
): ServiceModules {
|
): ServiceModules {
|
||||||
const storageAccessManager = new StorageAccessManager();
|
const storageAccessManager = new StorageAccessManager();
|
||||||
|
|
||||||
@@ -42,6 +45,12 @@ export function initialiseServiceModulesCLI(
|
|||||||
vaultService: services.vault,
|
vaultService: services.vault,
|
||||||
storageAccessManager: storageAccessManager,
|
storageAccessManager: storageAccessManager,
|
||||||
APIService: services.API,
|
APIService: services.API,
|
||||||
|
}, ignoreRules, watchEnabled);
|
||||||
|
|
||||||
|
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||||
|
services.appLifecycle.onUnload.addHandler(async () => {
|
||||||
|
await storageEventManager.close();
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Storage access using CLI file system adapter
|
// Storage access using CLI file system adapter
|
||||||
|
|||||||
129
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
129
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { minimatch } from "minimatch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and evaluates ignore rules from `.livesync/ignore` inside the vault.
|
||||||
|
*
|
||||||
|
* File format:
|
||||||
|
* - Lines starting with `#` are comments.
|
||||||
|
* - Blank lines are ignored.
|
||||||
|
* - `import: .gitignore` (exactly) — merges patterns from the vault's `.gitignore`.
|
||||||
|
* - All other lines are minimatch glob patterns relative to the vault root.
|
||||||
|
*
|
||||||
|
* Negation patterns (lines starting with `!`) are not supported. Loading a
|
||||||
|
* ruleset containing them throws an error — use separate include/exclude files
|
||||||
|
* instead.
|
||||||
|
*
|
||||||
|
* Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped.
|
||||||
|
*/
|
||||||
|
export class IgnoreRules {
|
||||||
|
private patterns: string[] = [];
|
||||||
|
|
||||||
|
constructor(private vaultPath: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
|
||||||
|
* pattern list. Safe to call multiple times — each call replaces the
|
||||||
|
* previous state. Does not throw if files are absent.
|
||||||
|
*
|
||||||
|
* @throws if any pattern line begins with `!` (negation is unsupported).
|
||||||
|
*/
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.patterns = [];
|
||||||
|
const ignorePath = path.join(this.vaultPath, ".livesync", "ignore");
|
||||||
|
let rawLines: string[];
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(ignorePath, "utf-8");
|
||||||
|
rawLines = content.split(/\r?\n/);
|
||||||
|
} catch {
|
||||||
|
// File absent or unreadable — treat as empty ruleset.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of rawLines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// NOTE: Only the exact string "import: .gitignore" is recognised.
|
||||||
|
// Any future generalisation of this directive must validate that
|
||||||
|
// the resolved path stays within the vault directory.
|
||||||
|
if (trimmed === "import: .gitignore") {
|
||||||
|
await this._importGitignore();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("import:")) {
|
||||||
|
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this._addPattern(trimmed);
|
||||||
|
}
|
||||||
|
if (this.patterns.length > 0) {
|
||||||
|
console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalises a single gitignore-style pattern:
|
||||||
|
// - Patterns ending with `/` (directory patterns like `build/`) are
|
||||||
|
// converted to `build/**` so they match all files inside that directory.
|
||||||
|
// - Patterns without a `/` are prefixed with `**/` to give them matchBase
|
||||||
|
// semantics (e.g. `*.tmp` → `**/*.tmp`), matching the basename in any
|
||||||
|
// subdirectory as gitignore does.
|
||||||
|
// - Patterns that already contain a `/` (but don't end with one) are
|
||||||
|
// path-specific and used as-is.
|
||||||
|
private _normalisePattern(pattern: string): string {
|
||||||
|
if (pattern.endsWith("/")) {
|
||||||
|
return "**/" + pattern + "**";
|
||||||
|
} else if (!pattern.includes("/")) {
|
||||||
|
return "**/" + pattern;
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _importGitignore(): Promise<void> {
|
||||||
|
const gitignorePath = path.join(this.vaultPath, ".gitignore");
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(gitignorePath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._parseLines(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseLines(content: string): void {
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
this._addPattern(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addPattern(raw: string): void {
|
||||||
|
if (raw.startsWith("!")) {
|
||||||
|
throw new Error(
|
||||||
|
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
||||||
|
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.patterns.push(this._normalisePattern(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if the given vault-relative path matches any loaded
|
||||||
|
* ignore pattern.
|
||||||
|
*
|
||||||
|
* @param relativePath - Path relative to the vault root, using forward
|
||||||
|
* slashes or the OS separator.
|
||||||
|
*/
|
||||||
|
shouldIgnore(relativePath: string): boolean {
|
||||||
|
if (this.patterns.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Normalise to forward slashes for minimatch.
|
||||||
|
const normalised = relativePath.replace(/\\/g, "/");
|
||||||
|
return this.patterns.some((p) => minimatch(normalised, p, { dot: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
172
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { IgnoreRules } from "./IgnoreRules";
|
||||||
|
|
||||||
|
describe("IgnoreRules", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function createVault(): Promise<string> {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-ignorerules-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIgnoreFile(vaultPath: string, content: string): Promise<void> {
|
||||||
|
const ignoreDir = path.join(vaultPath, ".livesync");
|
||||||
|
await fs.mkdir(ignoreDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pattern normalisation", () => {
|
||||||
|
it("adds **/ prefix to basename patterns (no slash)", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("deep/nested/file.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends ** to directory patterns ending with / and prepends **/", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "build/\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("build/nested/file.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("subproject/build/output.js")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves patterns containing / as-is", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "docs/private.md\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("docs/private.md")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("other/docs/private.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldIgnore", () => {
|
||||||
|
it("matches **/*.tmp against notes/scratch.tmp", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match notes/readme.md against **/*.tmp", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/readme.md")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no patterns are loaded", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
// No load() call — patterns are empty
|
||||||
|
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("negation patterns", () => {
|
||||||
|
it("throws when a negation pattern is encountered", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n!important.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a .gitignore imported via directive contains negation", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n!keep.log\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unrecognised import: directives", () => {
|
||||||
|
it("warns and skips unrecognised import: forms (does not add as literal pattern)", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
// Typo: "import:.gitignore" instead of "import: .gitignore"
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("import:.gitignore")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load() with missing file", () => {
|
||||||
|
it("returns without error when .livesync/ignore is absent", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
// No ignore file created
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).resolves.toBeUndefined();
|
||||||
|
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load() with comments and blank lines", () => {
|
||||||
|
it("skips # comment lines and blank lines", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(
|
||||||
|
vaultPath,
|
||||||
|
"# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"
|
||||||
|
);
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("readme.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import: .gitignore directive", () => {
|
||||||
|
it("reads and normalises patterns from .gitignore", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\nnode_modules/\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("app.log")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("node_modules/package.json")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("src/node_modules/package.json")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("src/index.ts")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges .gitignore patterns with other patterns", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("error.log")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import: .gitignore with missing .gitignore", () => {
|
||||||
|
it("does not throw when .gitignore is absent", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||||
|
// No .gitignore created
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).resolves.toBeUndefined();
|
||||||
|
// The *.tmp pattern from the ignore file still works
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test: daemon-related ignore rules behaviour
|
||||||
|
#
|
||||||
|
# Tests that are runnable without a long-running daemon process are exercised
|
||||||
|
# here using the `mirror` command, which calls the same `isTargetFile` handler
|
||||||
|
# stack that the daemon uses.
|
||||||
|
#
|
||||||
|
# Covered cases:
|
||||||
|
# 1. .livesync/ignore with *.tmp pattern → ignored file is NOT synced to DB
|
||||||
|
# 2. .livesync/ignore missing → no error, normal sync continues
|
||||||
|
# 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$CLI_DIR"
|
||||||
|
source "$SCRIPT_DIR/test-helpers.sh"
|
||||||
|
display_test_info
|
||||||
|
|
||||||
|
RUN_BUILD="${RUN_BUILD:-1}"
|
||||||
|
cli_test_init_cli_cmd
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-daemon-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
SETTINGS_FILE="$WORK_DIR/data.json"
|
||||||
|
VAULT_DIR="$WORK_DIR/vault"
|
||||||
|
mkdir -p "$VAULT_DIR/notes"
|
||||||
|
|
||||||
|
if [[ "$RUN_BUILD" == "1" ]]; then
|
||||||
|
echo "[INFO] building CLI..."
|
||||||
|
npm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] generating settings -> $SETTINGS_FILE"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_pass() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
|
||||||
|
assert_fail() { echo "[FAIL] $1" >&2; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 1: .livesync/ignore with *.tmp → matched file should NOT appear in DB
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 1: .livesync/ignore *.tmp → ignored file not synced to DB ==="
|
||||||
|
|
||||||
|
mkdir -p "$VAULT_DIR/.livesync"
|
||||||
|
printf '*.tmp\n' > "$VAULT_DIR/.livesync/ignore"
|
||||||
|
|
||||||
|
# Also write a normal file so we can confirm mirror ran at all.
|
||||||
|
printf 'normal content\n' > "$VAULT_DIR/notes/normal.md"
|
||||||
|
# Write the file that should be ignored.
|
||||||
|
printf 'tmp content\n' > "$VAULT_DIR/notes/scratch.tmp"
|
||||||
|
|
||||||
|
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror
|
||||||
|
|
||||||
|
# The normal file should be in the DB.
|
||||||
|
RESULT_NORMAL="$WORK_DIR/case1-normal.txt"
|
||||||
|
if run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" pull notes/normal.md "$RESULT_NORMAL" 2>/dev/null; then
|
||||||
|
if cmp -s "$VAULT_DIR/notes/normal.md" "$RESULT_NORMAL"; then
|
||||||
|
assert_pass "normal.md was synced to DB"
|
||||||
|
else
|
||||||
|
assert_fail "normal.md content mismatch after mirror"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
assert_fail "normal.md was not found in DB after mirror"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The .tmp file should NOT be in the DB.
|
||||||
|
DB_LIST="$WORK_DIR/case1-ls.txt"
|
||||||
|
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" ls > "$DB_LIST"
|
||||||
|
if grep -q "scratch.tmp" "$DB_LIST"; then
|
||||||
|
assert_fail "scratch.tmp (ignored) was unexpectedly synced to DB"
|
||||||
|
echo "--- DB listing ---" >&2; cat "$DB_LIST" >&2
|
||||||
|
else
|
||||||
|
assert_pass "scratch.tmp (*.tmp pattern) was NOT synced to DB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 2: .livesync/ignore absent → no error, normal sync continues
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 2: .livesync/ignore absent → no error, sync continues ==="
|
||||||
|
|
||||||
|
VAULT_DIR2="$WORK_DIR/vault2"
|
||||||
|
mkdir -p "$VAULT_DIR2/notes"
|
||||||
|
SETTINGS_FILE2="$WORK_DIR/data2.json"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE2"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE2"
|
||||||
|
|
||||||
|
# No .livesync directory at all.
|
||||||
|
printf 'hello\n' > "$VAULT_DIR2/notes/hello.md"
|
||||||
|
|
||||||
|
# mirror should succeed without error.
|
||||||
|
set +e
|
||||||
|
MIRROR_OUTPUT="$WORK_DIR/case2-mirror.txt"
|
||||||
|
run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" mirror >"$MIRROR_OUTPUT" 2>&1
|
||||||
|
MIRROR_EXIT=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$MIRROR_EXIT" -ne 0 ]]; then
|
||||||
|
assert_fail "mirror exited non-zero ($MIRROR_EXIT) when .livesync/ignore is absent"
|
||||||
|
cat "$MIRROR_OUTPUT" >&2
|
||||||
|
else
|
||||||
|
assert_pass "mirror succeeded when .livesync/ignore is absent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The normal file should have been synced.
|
||||||
|
RESULT_HELLO="$WORK_DIR/case2-hello.txt"
|
||||||
|
if run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" pull notes/hello.md "$RESULT_HELLO" 2>/dev/null; then
|
||||||
|
assert_pass "file synced normally when .livesync/ignore is absent"
|
||||||
|
else
|
||||||
|
assert_fail "file was not synced when .livesync/ignore is absent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 3: import: .gitignore merges patterns
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 3: import: .gitignore directive merges patterns ==="
|
||||||
|
|
||||||
|
VAULT_DIR3="$WORK_DIR/vault3"
|
||||||
|
mkdir -p "$VAULT_DIR3/notes"
|
||||||
|
SETTINGS_FILE3="$WORK_DIR/data3.json"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE3"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE3"
|
||||||
|
|
||||||
|
mkdir -p "$VAULT_DIR3/.livesync"
|
||||||
|
printf 'import: .gitignore\n' > "$VAULT_DIR3/.livesync/ignore"
|
||||||
|
printf '# gitignore comment\n*.log\nbuild/\n' > "$VAULT_DIR3/.gitignore"
|
||||||
|
|
||||||
|
printf 'regular note\n' > "$VAULT_DIR3/notes/regular.md"
|
||||||
|
printf 'log content\n' > "$VAULT_DIR3/notes/debug.log"
|
||||||
|
|
||||||
|
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" mirror
|
||||||
|
|
||||||
|
DB_LIST3="$WORK_DIR/case3-ls.txt"
|
||||||
|
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" ls > "$DB_LIST3"
|
||||||
|
|
||||||
|
if grep -q "debug.log" "$DB_LIST3"; then
|
||||||
|
assert_fail "debug.log (ignored via .gitignore import) was unexpectedly synced to DB"
|
||||||
|
echo "--- DB listing ---" >&2; cat "$DB_LIST3" >&2
|
||||||
|
else
|
||||||
|
assert_pass "debug.log (*.log from imported .gitignore) was NOT synced to DB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# regular.md should still be present.
|
||||||
|
if grep -q "regular.md" "$DB_LIST3"; then
|
||||||
|
assert_pass "regular.md was synced normally alongside .gitignore import rules"
|
||||||
|
else
|
||||||
|
assert_fail "regular.md was NOT synced — .gitignore import may have been too broad"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Summary
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "Results: PASS=$PASS FAIL=$FAIL"
|
||||||
|
if [[ "$FAIL" -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
9
src/apps/cli/testdeno/.test.env
Normal file
9
src/apps/cli/testdeno/.test.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
hostname=http://127.0.0.1:5989/
|
||||||
|
dbname=livesync-test-db-ci
|
||||||
|
username=admin
|
||||||
|
password=testpassword
|
||||||
|
minioEndpoint=http://127.0.0.1:9000
|
||||||
|
accessKey=minioadmin
|
||||||
|
secretKey=minioadmin
|
||||||
|
bucketName=livesync-test-bucket-ci
|
||||||
|
LIVESYNC_TEST_TEE=1
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno test -A --no-check test-*.ts",
|
"test": "deno test --env-file=.test.env -A --no-check test-*.ts",
|
||||||
"test:local": "deno test -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
||||||
"test:push-pull": "deno test -A --no-check test-push-pull.ts",
|
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||||
"test:setup-put-cat": "deno test -A --no-check test-setup-put-cat.ts",
|
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||||
"test:mirror": "deno test -A --no-check test-mirror.ts",
|
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||||
"test:sync-two-local": "deno test -A --no-check test-sync-two-local-databases.ts",
|
"test:sync-two-local": "deno test --env-file=.test.env -A --no-check test-sync-two-local-databases.ts",
|
||||||
"test:sync-locked-remote": "deno test -A --no-check test-sync-locked-remote.ts",
|
"test:sync-locked-remote": "deno test --env-file=.test.env -A --no-check test-sync-locked-remote.ts",
|
||||||
"test:p2p-host": "deno test -A --no-check test-p2p-host.ts",
|
"test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts",
|
||||||
"test:p2p-peers": "deno test -A --no-check test-p2p-peers-local-relay.ts",
|
"test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts",
|
||||||
"test:p2p-sync": "deno test -A --no-check test-p2p-sync.ts",
|
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
|
||||||
"test:p2p-three-nodes": "deno test -A --no-check test-p2p-three-nodes-conflict.ts",
|
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
|
||||||
"test:p2p-upload-download": "deno test -A --no-check test-p2p-upload-download-repro.ts",
|
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
|
||||||
"test:e2e-couchdb": "deno test -A --no-check test-e2e-two-vaults-couchdb.ts",
|
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
||||||
"test:e2e-matrix": "deno test -A --no-check test-e2e-two-vaults-matrix.ts"
|
"test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { assert } from "@std/assert";
|
import { assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import {
|
import {
|
||||||
runCli,
|
runCli,
|
||||||
runCliOrFail,
|
runCliOrFail,
|
||||||
@@ -11,31 +10,29 @@ import {
|
|||||||
} from "./helpers/cli.ts";
|
} from "./helpers/cli.ts";
|
||||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
||||||
import { join } from "@std/path";
|
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
type RemoteType = "COUCHDB" | "MINIO";
|
type RemoteType = "COUCHDB" | "MINIO";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) throw new Error(`Required env var is missing: ${key}`);
|
const value = Deno.env.get(key)?.trim();
|
||||||
return value;
|
if (value) return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
|
||||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
const couchdbUri = remoteType === "COUCHDB" ? requireEnv(env, "hostname").replace(/\/$/, "") : "";
|
const couchdbUri = remoteType === "COUCHDB" ? requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "") : "";
|
||||||
const couchdbUser = remoteType === "COUCHDB" ? requireEnv(env, "username") : "";
|
const couchdbUser = remoteType === "COUCHDB" ? requireEnv("COUCHDB_USER", "username") : "";
|
||||||
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv(env, "password") : "";
|
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv("COUCHDB_PASSWORD", "password") : "";
|
||||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv(env, "dbname") : "";
|
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||||
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
||||||
|
|
||||||
const minioEndpoint = remoteType === "MINIO" ? requireEnv(env, "minioEndpoint").replace(/\/$/, "") : "";
|
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv(env, "accessKey") : "";
|
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv(env, "secretKey") : "";
|
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv(env, "bucketName") : "";
|
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||||
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
||||||
|
|
||||||
const passphrase = "e2e-passphrase";
|
const passphrase = "e2e-passphrase";
|
||||||
|
|||||||
@@ -6,30 +6,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert, assertStringIncludes } from "@std/assert";
|
import { assert, assertStringIncludes } from "@std/assert";
|
||||||
import { join } from "@std/path";
|
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) {
|
const value = Deno.env.get(key)?.trim();
|
||||||
throw new Error(`Required env var is missing: ${key}`);
|
if (value) return value;
|
||||||
}
|
}
|
||||||
return value;
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("sync: actionable error against locked remote DB", async () => {
|
Deno.test("sync: actionable error against locked remote DB", async () => {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
const couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "");
|
||||||
const couchdbUri = requireEnv(env, "hostname").replace(/\/$/, "");
|
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
||||||
const couchdbUser = requireEnv(env, "username");
|
const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password");
|
||||||
const couchdbPassword = requireEnv(env, "password");
|
const dbPrefix = requireEnv("COUCHDB_DBNAME", "dbname");
|
||||||
const dbPrefix = requireEnv(env, "dbname");
|
|
||||||
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
||||||
|
|||||||
@@ -23,13 +23,11 @@
|
|||||||
* deno test -A test-sync-two-local-databases.ts
|
* deno test -A test-sync-two-local-databases.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
|
||||||
import { assertEquals, assert } from "@std/assert";
|
import { assertEquals, assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { CLI_DIR, runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load configuration
|
// Load configuration
|
||||||
@@ -41,20 +39,7 @@ async function resolveConfig(): Promise<{
|
|||||||
password: string;
|
password: string;
|
||||||
baseDbname: string;
|
baseDbname: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let env: Record<string, string> = {};
|
const env = Deno.env.toObject();
|
||||||
|
|
||||||
// 1. Explicit environment variables take priority
|
|
||||||
if (Deno.env.get("COUCHDB_URI")) {
|
|
||||||
env = Object.fromEntries(Deno.env.toObject());
|
|
||||||
} else {
|
|
||||||
// 2. TEST_ENV_FILE env var
|
|
||||||
const envFile = Deno.env.get("TEST_ENV_FILE") ?? join(CLI_DIR, ".test.env");
|
|
||||||
try {
|
|
||||||
env = await loadEnvFile(envFile);
|
|
||||||
} catch {
|
|
||||||
return null; // no config available — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
||||||
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
||||||
|
|||||||
@@ -11,11 +11,54 @@ const defaultExternal = [
|
|||||||
"crypto",
|
"crypto",
|
||||||
"pouchdb-adapter-leveldb",
|
"pouchdb-adapter-leveldb",
|
||||||
"commander",
|
"commander",
|
||||||
|
"chokidar",
|
||||||
"punycode",
|
"punycode",
|
||||||
"werift",
|
"werift",
|
||||||
];
|
];
|
||||||
|
// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses
|
||||||
|
// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is
|
||||||
|
// unavailable. Node.js has neither, so we inject a minimal FileReader shim before
|
||||||
|
// any module-scope code evaluates.
|
||||||
|
const fileReaderPolyfillBanner = `
|
||||||
|
if (typeof globalThis.FileReader === "undefined") {
|
||||||
|
globalThis.FileReader = class FileReader {
|
||||||
|
constructor() { this.result = null; this.onload = null; this.onerror = null; }
|
||||||
|
readAsDataURL(blob) {
|
||||||
|
blob.arrayBuffer().then((buf) => {
|
||||||
|
var b64 = require("buffer").Buffer.from(buf).toString("base64");
|
||||||
|
this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64;
|
||||||
|
if (this.onload) this.onload({ target: this });
|
||||||
|
}).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); });
|
||||||
|
}
|
||||||
|
readAsArrayBuffer() { throw new Error("FileReader.readAsArrayBuffer is not implemented in this polyfill"); }
|
||||||
|
readAsBinaryString() { throw new Error("FileReader.readAsBinaryString is not implemented in this polyfill"); }
|
||||||
|
readAsText() { throw new Error("FileReader.readAsText is not implemented in this polyfill"); }
|
||||||
|
abort() { throw new Error("FileReader.abort is not implemented in this polyfill"); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectBanner(): import("vite").Plugin {
|
||||||
|
return {
|
||||||
|
name: "inject-banner",
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
for (const chunk of Object.values(bundle)) {
|
||||||
|
if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) {
|
||||||
|
// Insert after the shebang line if present, otherwise at the top.
|
||||||
|
if (chunk.code.startsWith("#!")) {
|
||||||
|
const newline = chunk.code.indexOf("\n");
|
||||||
|
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||||
|
} else {
|
||||||
|
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte(), injectBanner()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
||||||
|
|||||||
2
src/lib
2
src/lib
Submodule src/lib updated: 16ed161ffa...adcfe42522
@@ -66,6 +66,11 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
currentDeleted = false;
|
currentDeleted = false;
|
||||||
initialRev?: string;
|
initialRev?: string;
|
||||||
|
|
||||||
|
// Diff navigation state
|
||||||
|
currentDiffIndex = -1;
|
||||||
|
diffNavContainer!: HTMLDivElement;
|
||||||
|
diffNavIndicator!: HTMLSpanElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
core: LiveSyncBaseCore,
|
core: LiveSyncBaseCore,
|
||||||
@@ -216,6 +221,64 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
this.contentView.innerHTML =
|
this.contentView.innerHTML =
|
||||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||||
}
|
}
|
||||||
|
// Reset diff navigation after content changes
|
||||||
|
this.resetDiffNavigation();
|
||||||
|
if (this.showDiff) {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the previous or next diff block in the content view.
|
||||||
|
* Only effective when diff highlighting is enabled.
|
||||||
|
*/
|
||||||
|
navigateDiff(direction: "prev" | "next") {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
if (diffElements.length === 0) return;
|
||||||
|
|
||||||
|
// Remove previous focus highlight
|
||||||
|
const prevFocused = this.contentView.querySelector(".diff-focused");
|
||||||
|
if (prevFocused) {
|
||||||
|
prevFocused.classList.remove("diff-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "next") {
|
||||||
|
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||||
|
} else {
|
||||||
|
this.currentDiffIndex =
|
||||||
|
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = diffElements[this.currentDiffIndex];
|
||||||
|
target.classList.add("diff-focused");
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
|
this.diffNavIndicator.textContent = `${this.currentDiffIndex + 1}/${diffElements.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the diff navigation index and update the indicator.
|
||||||
|
*/
|
||||||
|
resetDiffNavigation() {
|
||||||
|
this.currentDiffIndex = -1;
|
||||||
|
if (this.diffNavIndicator) {
|
||||||
|
if (this.showDiff) {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
this.diffNavIndicator.textContent = diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014";
|
||||||
|
} else {
|
||||||
|
this.diffNavIndicator.textContent = "\u2014";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateDiffNavVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the diff navigation buttons based on the showDiff state.
|
||||||
|
*/
|
||||||
|
updateDiffNavVisibility() {
|
||||||
|
if (this.diffNavContainer) {
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onOpen() {
|
override onOpen() {
|
||||||
@@ -236,9 +299,11 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
contentEl
|
const diffOptionsRow = contentEl.createDiv("");
|
||||||
.createDiv("", (e) => {
|
diffOptionsRow.addClass("op-info");
|
||||||
e.createEl("label", {}, (label) => {
|
diffOptionsRow.addClass("diff-options-row");
|
||||||
|
|
||||||
|
diffOptionsRow.createEl("label", {}, (label) => {
|
||||||
label.appendChild(
|
label.appendChild(
|
||||||
createEl("input", { type: "checkbox" }, (checkbox) => {
|
createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||||
if (this.showDiff) {
|
if (this.showDiff) {
|
||||||
@@ -247,14 +312,34 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
checkbox.addEventListener("input", (evt: any) => {
|
checkbox.addEventListener("input", (evt: any) => {
|
||||||
this.showDiff = checkbox.checked;
|
this.showDiff = checkbox.checked;
|
||||||
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
||||||
|
this.updateDiffNavVisibility();
|
||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
label.appendText("Highlight diff");
|
label.appendText("Highlight diff");
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.addClass("op-info");
|
// Diff navigation buttons
|
||||||
|
this.diffNavContainer = diffOptionsRow.createDiv("");
|
||||||
|
this.diffNavContainer.addClass("diff-nav");
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
|
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("prev");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavIndicator = this.diffNavContainer.createEl("span", { text: "\u2014" });
|
||||||
|
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||||
|
|
||||||
this.info = contentEl.createDiv("");
|
this.info = contentEl.createDiv("");
|
||||||
this.info.addClass("op-info");
|
this.info.addClass("op-info");
|
||||||
fireAndForget(async () => await this.loadFile(this.initialRev));
|
fireAndForget(async () => await this.loadFile(this.initialRev));
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types.ts";
|
|
||||||
import { configURIBase } from "../../common/types.ts";
|
|
||||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
|
||||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
|
||||||
import {
|
|
||||||
EVENT_REQUEST_COPY_SETUP_URI,
|
|
||||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
|
||||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
|
||||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
|
||||||
eventHub,
|
|
||||||
} from "../../common/events.ts";
|
|
||||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
|
||||||
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
|
||||||
import type { LiveSyncCore } from "../../main.ts";
|
|
||||||
import {
|
|
||||||
encodeQR,
|
|
||||||
encodeSettingsToQRCodeData,
|
|
||||||
encodeSettingsToSetupURI,
|
|
||||||
OutputFormat,
|
|
||||||
} from "../../lib/src/API/processSetting.ts";
|
|
||||||
import { SetupManager, UserMode } from "./SetupManager.ts";
|
|
||||||
import { AbstractModule } from "../AbstractModule.ts";
|
|
||||||
|
|
||||||
export class ModuleSetupObsidian extends AbstractModule {
|
|
||||||
private _setupManager!: SetupManager;
|
|
||||||
private _everyOnload(): Promise<boolean> {
|
|
||||||
this._setupManager = this.core.getModule(SetupManager);
|
|
||||||
try {
|
|
||||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
|
||||||
if (conf.settings) {
|
|
||||||
await this._setupManager.onUseSetupURI(
|
|
||||||
UserMode.Unknown,
|
|
||||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
|
||||||
);
|
|
||||||
} else if (conf.settingsQR) {
|
|
||||||
await this._setupManager.decodeQR(conf.settingsQR);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this._log(
|
|
||||||
"Failed to register protocol handler. This feature may not work in some environments.",
|
|
||||||
LOG_LEVEL_NOTICE
|
|
||||||
);
|
|
||||||
this._log(e, LOG_LEVEL_VERBOSE);
|
|
||||||
}
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-setting-qr",
|
|
||||||
name: "Show settings as a QR code",
|
|
||||||
callback: () => fireAndForget(this.encodeQR()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri",
|
|
||||||
name: "Copy settings as a new setup URI",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURI()),
|
|
||||||
});
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri-short",
|
|
||||||
name: "Copy settings as a new setup URI (With customization sync)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIWithSync()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupurifull",
|
|
||||||
name: "Copy settings as a new setup URI (Full)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIFull()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-opensetupuri",
|
|
||||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
|
||||||
callback: () => fireAndForget(this.command_openSetupURI()),
|
|
||||||
});
|
|
||||||
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
|
|
||||||
fireAndForget(() => {
|
|
||||||
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
async encodeQR() {
|
|
||||||
const settingString = encodeSettingsToQRCodeData(this.settings);
|
|
||||||
const codeSVG = encodeQR(settingString, OutputFormat.SVG);
|
|
||||||
if (codeSVG == "") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const msg = $msg("Setup.QRCode", { qr_image: codeSVG });
|
|
||||||
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
|
|
||||||
return await Promise.resolve(codeSVG);
|
|
||||||
}
|
|
||||||
|
|
||||||
async askEncryptingPassphrase(): Promise<string | false> {
|
|
||||||
const encryptingPassphrase = await this.core.confirm.askString(
|
|
||||||
"Encrypt your settings",
|
|
||||||
"The passphrase to encrypt the setup URI",
|
|
||||||
"",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
return encryptingPassphrase;
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURI(stripExtra = true) {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(
|
|
||||||
this.settings,
|
|
||||||
encryptingPassphrase,
|
|
||||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (await this.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
// await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIFull() {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(this.settings, encryptingPassphrase, [], false);
|
|
||||||
await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIWithSync() {
|
|
||||||
await this.command_copySetupURI(false);
|
|
||||||
}
|
|
||||||
async command_openSetupURI() {
|
|
||||||
await this._setupManager.onUseSetupURI(UserMode.Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Where to implement these?
|
|
||||||
|
|
||||||
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
|
||||||
// const buttons = {
|
|
||||||
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
|
||||||
// no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
|
|
||||||
// } as const;
|
|
||||||
// const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.FetchRemoteConf.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
|
|
||||||
// );
|
|
||||||
// if (fetchRemoteConf == buttons.no) {
|
|
||||||
// return tryingSettings;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
|
||||||
// if (remoteConfig) {
|
|
||||||
// this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
|
||||||
// const resultSettings = {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// ...remoteConfig,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// return resultSettings;
|
|
||||||
// } else {
|
|
||||||
// this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// async askPerformDoctor(
|
|
||||||
// tryingSettings: ObsidianLiveSyncSettings
|
|
||||||
// ): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
|
|
||||||
// const buttons = {
|
|
||||||
// yes: $msg("Setup.Doctor.Buttons.Yes"),
|
|
||||||
// no: $msg("Setup.Doctor.Buttons.No"),
|
|
||||||
// } as const;
|
|
||||||
// const performDoctor = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.Doctor.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
|
|
||||||
// );
|
|
||||||
// if (performDoctor == buttons.no) {
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
|
|
||||||
// localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
|
|
||||||
// remoteRebuild: RebuildOptions.SkipEvenIfRequired,
|
|
||||||
// activateReason: "New settings from URI",
|
|
||||||
// });
|
|
||||||
// if (isModified) {
|
|
||||||
// this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// settings,
|
|
||||||
// shouldRebuild,
|
|
||||||
// isModified,
|
|
||||||
// };
|
|
||||||
// } else {
|
|
||||||
// this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
|
||||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
SuffixDatabaseName,
|
SuffixDatabaseName,
|
||||||
} from "../../../lib/src/common/types.ts";
|
} from "../../../lib/src/common/types.ts";
|
||||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||||
import { generateUserHashSalt } from "../../../lib/src/common/utils.ts";
|
|
||||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||||
import type { PageFunctions } from "./SettingPane.ts";
|
import type { PageFunctions } from "./SettingPane.ts";
|
||||||
@@ -157,42 +156,6 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
|||||||
await this.core.localDatabase._prepareHashFunctions();
|
await this.core.localDatabase._prepareHashFunctions();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void addPanel(paneEl, "Chunk ID Namespace").then((paneEl) => {
|
|
||||||
paneEl.createDiv({
|
|
||||||
text: "Manage the Chunk ID Namespace Salt (userHashSalt). This value is used as a seed for generating chunk IDs. If you change this value, chunk IDs will be regenerated and you must rebuild the database.",
|
|
||||||
cls: "op-warn-info",
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(paneEl)
|
|
||||||
.autoWireText("userHashSalt", { holdValue: true })
|
|
||||||
.setClass("wizardHidden")
|
|
||||||
.addApplyButton(["userHashSalt"]);
|
|
||||||
|
|
||||||
new Setting(paneEl)
|
|
||||||
.setName("Generate New Salt")
|
|
||||||
.setDesc(
|
|
||||||
"Generate a new random salt for the Chunk ID namespace. After generating, a database rebuild is strongly recommended."
|
|
||||||
)
|
|
||||||
.addButton((button) => {
|
|
||||||
button
|
|
||||||
.setButtonText("Generate New Salt")
|
|
||||||
.setCta()
|
|
||||||
.onClick(async () => {
|
|
||||||
const confirmed = await this.core.confirm.askYesNo(
|
|
||||||
"Generating a new salt will invalidate existing chunk IDs. Until you rebuild the database, deduplication will be inefficient. Are you sure to generate a new salt now?"
|
|
||||||
);
|
|
||||||
if (confirmed) {
|
|
||||||
const newSalt = generateUserHashSalt();
|
|
||||||
this.editingSettings.userHashSalt = newSalt;
|
|
||||||
await this.saveSettings(["userHashSalt"]);
|
|
||||||
Logger(`New Chunk ID Namespace Salt generated.`, LOG_LEVEL_NOTICE);
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
void addPanel(paneEl, "Edge case addressing (Behaviour)").then((paneEl) => {
|
void addPanel(paneEl, "Edge case addressing (Behaviour)").then((paneEl) => {
|
||||||
new Setting(paneEl).autoWireToggle("doNotSuspendOnFetching");
|
new Setting(paneEl).autoWireToggle("doNotSuspendOnFetching");
|
||||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
|
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
REMOTE_MINIO,
|
REMOTE_MINIO,
|
||||||
REMOTE_P2P,
|
REMOTE_P2P,
|
||||||
} from "../../lib/src/common/types.ts";
|
} from "../../lib/src/common/types.ts";
|
||||||
import { generatePatchObj, isObjectDifferent, generateUserHashSalt } from "../../lib/src/common/utils.ts";
|
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
|
||||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||||
@@ -328,9 +328,6 @@ export class SetupManager extends AbstractModule {
|
|||||||
}
|
}
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
extra();
|
extra();
|
||||||
if (userMode === UserMode.NewUser && !newConf.userHashSalt) {
|
|
||||||
newConf.userHashSalt = generateUserHashSalt();
|
|
||||||
}
|
|
||||||
await this.applySetting(newConf, userMode);
|
await this.applySetting(newConf, userMode);
|
||||||
if (userMode === UserMode.NewUser) {
|
if (userMode === UserMode.NewUser) {
|
||||||
// For new users, schedule a rebuild everything.
|
// For new users, schedule a rebuild everything.
|
||||||
|
|||||||
@@ -154,47 +154,4 @@ describe("SetupManager", () => {
|
|||||||
);
|
);
|
||||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("onConfirmApplySettingsFromWizard should generate userHashSalt for NewUser when absent", async () => {
|
|
||||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
|
||||||
const randomSpy = vi.spyOn(globalThis.crypto, "getRandomValues").mockImplementation((array) => {
|
|
||||||
const target = array as Uint8Array;
|
|
||||||
for (let i = 0; i < target.length; i++) {
|
|
||||||
target[i] = 0xab;
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
});
|
|
||||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true);
|
|
||||||
|
|
||||||
await manager.onConfirmApplySettingsFromWizard(
|
|
||||||
{
|
|
||||||
...setting.currentSettings(),
|
|
||||||
userHashSalt: "",
|
|
||||||
},
|
|
||||||
UserMode.NewUser
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(setting.currentSettings().userHashSalt).toBe("abababababababababababababababab");
|
|
||||||
expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledTimes(1);
|
|
||||||
randomSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("onConfirmApplySettingsFromWizard should keep existing userHashSalt for NewUser", async () => {
|
|
||||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
|
||||||
const randomSpy = vi.spyOn(globalThis.crypto, "getRandomValues");
|
|
||||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true);
|
|
||||||
|
|
||||||
await manager.onConfirmApplySettingsFromWizard(
|
|
||||||
{
|
|
||||||
...setting.currentSettings(),
|
|
||||||
userHashSalt: "00112233445566778899aabbccddeeff",
|
|
||||||
},
|
|
||||||
UserMode.NewUser
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(setting.currentSettings().userHashSalt).toBe("00112233445566778899aabbccddeeff");
|
|
||||||
expect(randomSpy).not.toHaveBeenCalled();
|
|
||||||
expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledTimes(1);
|
|
||||||
randomSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
|||||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||||
fireAndForget(async () => {
|
fireAndForget(async () => {
|
||||||
try {
|
try {
|
||||||
await this.core.services.control.applySettings();
|
const lang = this.core.services.setting.currentSettings()?.displayLanguage;
|
||||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage ?? undefined;
|
|
||||||
if (lang !== undefined) {
|
if (lang !== undefined) {
|
||||||
setLang(this.core.services.setting.currentSettings()?.displayLanguage);
|
setLang(lang);
|
||||||
|
}
|
||||||
|
if (this.core.services.database.isDatabaseReady()) {
|
||||||
|
await this.core.services.control.applySettings();
|
||||||
}
|
}
|
||||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
41
styles.css
41
styles.css
@@ -485,3 +485,44 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Diff navigation */
|
||||||
|
.diff-options-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-indicator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-focused {
|
||||||
|
outline: 2px solid var(--interactive-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
22
updates.md
22
updates.md
@@ -7,25 +7,13 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Chunk ID namespace is now separated from the E2EE passphrase by introducing `userHashSalt`.
|
- P2P synchronisation has been made more robust
|
||||||
- Chunk ID hashing now prefers `userHashSalt` when present, and falls back to the legacy passphrase-derived seed for compatibility.
|
Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation.
|
||||||
- New setup now generates `userHashSalt` automatically if it is missing.
|
This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated.
|
||||||
- `rebuildEverything` now generates `userHashSalt` only when it is missing, as a migration path for existing vaults.
|
|
||||||
- Setup URI / QR settings round-trip now preserves `userHashSalt`.
|
|
||||||
|
|
||||||
### Behaviour and safety
|
### Fixed
|
||||||
|
|
||||||
- `userHashSalt` has been added to tweak-value mismatch detection so devices can notice and resolve mismatched chunk-ID namespace settings.
|
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
|
||||||
- `userHashSalt` mismatch is treated as compatible but potentially lossy (inefficient), not hard-incompatible.
|
|
||||||
- Mismatch dialogues now mask `userHashSalt` values to avoid exposing the raw value in UI.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Added and updated unit tests for:
|
|
||||||
- `HashManager` (`userHashSalt` priority and differing-salt behaviour).
|
|
||||||
- `SetupManager` (generation only when missing, preserving existing value).
|
|
||||||
- `Rebuilder` (generation only when missing, no regeneration when present).
|
|
||||||
- `processSetting` setup URI round-trip and secure-field handling.
|
|
||||||
|
|
||||||
## 0.25.60
|
## 0.25.60
|
||||||
|
|
||||||
|
|||||||
30
vitest.config.rpc-unit.ts
Normal file
30
vitest.config.rpc-unit.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig, mergeConfig } from "vitest/config";
|
||||||
|
import viteConfig from "./vitest.config.common";
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
obsidian: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
name: "rpc-unit-tests",
|
||||||
|
include: ["src/lib/src/rpc/**/*.unit.spec.ts"],
|
||||||
|
exclude: ["test/**"],
|
||||||
|
coverage: {
|
||||||
|
include: ["src/lib/src/rpc/**/*.ts"],
|
||||||
|
exclude: ["**/*.unit.spec.ts", "**/index.ts"],
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html", ["text", { file: "coverage-rpc-text.txt" }]],
|
||||||
|
thresholds: {
|
||||||
|
lines: 90,
|
||||||
|
functions: 90,
|
||||||
|
branches: 75,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user