Compare commits

...
Author SHA1 Message Date
vorotamoroz a2441b9870 Integrate merged E2EE rebuild fix
# Conflicts:
#	updates.md
2026-09-02 12:34:58 +00:00
vorotamoroz 01c38268c7 Merge pull request #1149 from vrtmrz/fix/1146-preserve-e2ee-on-rebuild
Preserve device E2EE settings during remote rebuild
2026-09-02 21:32:54 +09:00
vorotamoroz 64e17ab920 Clarify systemd CLI installation guidance 2026-09-02 12:20:50 +00:00
vorotamoroz d8fccd6e4b Integrate systemd CLI installer fix with current main 2026-09-02 11:43:59 +00:00
vorotamoroz 50ad4c4bdf Integrate E2EE rebuild preservation with current main 2026-09-02 11:30:20 +00:00
vorotamoroz 8cb5d6d87f Adopt Replicator capability and lifecycle orchestration (#1154)
Merge the reviewed Replicator ownership and capability refactor after exact-release validation, stable promotion, and successful BRAT testing.
2026-09-02 16:59:20 +09:00
vorotamoroz 79eb940d80 Release Self-hosted LiveSync 1.0.23
Merge the exact reviewed release commit after published-artefact and BRAT validation.
2026-09-02 16:23:18 +09:00
vorotamoroz 28e76701c4 Add personal note to 1.0.23 release notes 2026-09-02 05:23:43 +00:00
github-actions[bot] c8aa40775b Releasing 1.0.23 2026-09-02 05:10:17 +00:00
vorotamoroz fdf5de4c10 Reconcile failed 1.0.22 release metadata 2026-09-02 04:57:10 +00:00
vorotamoroz 9e9fb5c326 chore: update livesync-commonlib to 0.1.21 2026-09-02 03:37:13 +00:00
vorotamoroz 2b450a300a Update vulnerable development dependencies 2026-09-01 18:25:27 +00:00
vorotamoroz ef3033d8db Restore Sync now progress and recovery policy 2026-09-01 18:00:08 +00:00
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
vorotamoroz b28871ab67 Preserve device E2EE settings during remote rebuild 2026-08-30 07:43:20 +00:00
28 changed files with 748 additions and 115 deletions
@@ -369,8 +369,17 @@ type InteractionAuthority =
const NO_INTERACTION = { kind: "forbidden" } as const;
const REPLICATION_PROGRESS_PRESENTATIONS = {
QUIET: "quiet",
NOTICE: "notice",
} as const;
type ReplicationProgressPresentation =
(typeof REPLICATION_PROGRESS_PRESENTATIONS)[keyof typeof REPLICATION_PROGRESS_PRESENTATIONS];
interface UserInitiatedOneShotRequest {
readonly trigger: "manual";
readonly progressPresentation: ReplicationProgressPresentation;
readonly interaction: InteractionAuthority;
}
@@ -393,11 +402,33 @@ same upper bound and expose only relevant permissions. An unattended path may
use persisted or automatic acceptance policy, but cannot obtain local
interaction authority implicitly.
The same authority bounds presentation. An unattended P2P path may retain an
informational diagnostic, but no-target, authentication, tweak-mismatch, and
overlapping-transfer settlements must not promote themselves to a Notice.
User-initiated paths retain their existing Notice-level presentation. This is
a caller-authority rule, not a new process-wide presentation framework.
Progress presentation and interaction authority are independent parts of the
request. `quiet` suppresses routine Notice-level progress, but does not remove
authorised peer selection or failure-recovery dialogue. `notice` permits
routine progress, but cannot grant an interaction which the request authority
forbids. Unattended work always uses `quiet` with `NO_INTERACTION`; it may retain
an informational diagnostic, but no-target, authentication, tweak-mismatch,
and overlapping-transfer settlements must not promote themselves to a Notice.
This remains a request-local policy, not a new process-wide presentation
framework.
The ordinary typed OneShot entry points deliberately retain these established
interaction shapes:
| Entry point | Routine progress | Interaction authority | Request received while another OneShot attempt is active |
| --- | --- | --- | --- |
| Command-palette `Sync now` | Quiet | Peer selection and failure recovery remain permitted | Settles as `blocked: replication-in-progress` |
| Ribbon synchronisation action | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| Config Sync `Sync once` | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| CLI `sync` | Notice | Full user-initiated authority, subject to the host's available interaction mechanisms | Settles as `blocked: replication-in-progress` |
| Resume, periodic, database-event, editor-save, file-open, merge, and daemon triggers | Quiet | `NO_INTERACTION` | Settles as `blocked: replication-in-progress` |
The OneShot owner is acquired before readiness evaluation and retained through
provider acquisition, finite activity, and failure handling. A later request
is not queued, joined to the running result, or treated as a failed transfer;
no work starts for that request. Directional Fetch and Rebuild, P2P-specific
commands, and other maintenance workflows remain outside this ordinary
OneShot gate and retain their own activity ownership.
Outcomes do not collapse failure into `void` or `boolean`:
+2
View File
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
The available actions depend on when the mismatch is found:
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.21",
"version": "1.0.23",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+35 -35
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.20",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4620,9 +4620,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.20",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.20.tgz",
"integrity": "sha512-d01aiejWGAH/8EDGeeZOUzMhTh14wV0IW2n6gpOPLzvTuQIA38lLMCweZN/n0YO/m2aa0TxBufe/YcYWRH6luA==",
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -5186,9 +5186,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5230,9 +5230,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -5250,11 +5250,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5355,9 +5355,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -5812,9 +5812,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.375",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz",
"integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==",
"version": "1.5.419",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.419.tgz",
"integrity": "sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==",
"dev": true,
"license": "ISC"
},
@@ -9524,9 +9524,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10154,9 +10154,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12292,9 +12292,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
@@ -12937,7 +12937,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.21-cli",
"version": "1.0.23-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -12962,7 +12962,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.21-webapp",
"version": "1.0.23-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
@@ -12974,7 +12974,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.21-webpeer",
"version": "1.0.23-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.20",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
+44 -32
View File
@@ -48,7 +48,7 @@ CLI Main
- Settings management (JSON file)
- Graceful shutdown handling
## Usage
## Command overview
The CLI operates on a **database directory** which contains PouchDB data and settings.
@@ -151,6 +151,46 @@ npm run cli -- [database-path] [command] [args...]
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
```
### systemd installation
The `deploy/` directory contains a systemd unit template and an install script.
**Automated installation (user service, recommended):**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
```
**With a polling interval:**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
```
**System-wide installation** (requires root or `sudo` for `/etc/systemd/system/`):
```bash
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Installs the repository dependencies and builds the CLI.
2. Installs the complete CLI bundle and its production dependencies under `~/.local/lib/livesync-cli` (user) or `/usr/local/lib/livesync-cli` (system), then checks that the installed CLI can start.
3. Installs the command wrapper as `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
4. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
5. Reloads systemd, enables and starts the service, and reports success only after confirming that the service remains active.
Ensure that `~/.local/bin` for a user installation, or `/usr/local/bin` for a system-wide installation, is on the shell's `PATH` before invoking `livesync-cli` interactively. For example, add the following to the appropriate shell start-up file for a user installation when needed:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
The generated systemd unit uses the wrapper's absolute path and does not depend on the shell's `PATH`.
**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 it in the appropriate systemd directory.
### Docker
A Docker image is provided for headless / server deployments. Build from the repository root:
@@ -210,7 +250,9 @@ candidate carries the host's public IP and peers can connect normally.
### Adding `livesync-cli` alias
To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`).
If you used the [systemd installer](#systemd-installation), no alias is required: it installs the `livesync-cli` wrapper in `~/.local/bin` or `/usr/local/bin`. If the installed command is not found, follow the `PATH` guidance in the systemd installation section.
The aliases below are only for running the CLI from a source checkout, or from Docker without using the installer. Add the appropriate alias to your shell configuration file, such as `.zshrc` or `.bashrc`.
If you are using `npm run`, add the following line:
@@ -506,36 +548,6 @@ Patterns apply in both directions: the chokidar watcher will not emit events for
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:
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
+2
View File
@@ -24,6 +24,7 @@ import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
isReplicationCompleted,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -173,6 +174,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStdoutLine(standardIo, "[Command] sync");
const result = await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
if (!isReplicationCompleted(result)) {
@@ -19,6 +19,7 @@ import {
REMOTE_RESOURCE_KINDS,
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_COMPLETED,
REPLICATION_PROGRESS_PRESENTATIONS,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -265,6 +266,21 @@ describe("runCommand abnormal cases", () => {
vi.restoreAllMocks();
});
it("retains visible progress for the interactive sync command", async () => {
const core = createCoreMock();
await expect(
runCommand(makeOptions("sync", []), {
...context,
core,
})
).resolves.toBe(true);
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
);
});
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
const core = createCoreMock();
core.services.replication.replicateUserInitiated.mockResolvedValue(
+57 -8
View File
@@ -8,7 +8,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
@@ -104,30 +104,70 @@ fi
# ── Install binary ───────────────────────────────────────────────────────────
if [[ "$INSTALL_MODE" == "user" ]]; then
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/livesync-cli"
UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMCTL_FLAGS="--user"
else
BIN_DIR="/usr/local/bin"
LIB_DIR="/usr/local/lib/livesync-cli"
UNIT_DIR="/etc/systemd/system"
SYSTEMCTL_FLAGS=""
fi
mkdir -p "$BIN_DIR"
LIB_PARENT="$(dirname -- "$LIB_DIR")"
mkdir -p "$BIN_DIR" "$LIB_PARENT"
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
LIVESYNC_JS="$LIB_DIR/dist/index.cjs"
# Copy the CJS bundle so the wrapper is self-contained and independent of the
# build directory location.
cp "$BUILT_CJS" "$LIVESYNC_JS"
# Build a complete runtime payload before replacing any previous installation.
# The Vite output contains hashed sibling chunks, while some Node dependencies
# deliberately remain external and must be installed next to the bundle.
PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")"
cleanup_payload() {
if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then
rm -rf -- "$PAYLOAD_STAGING"
fi
}
trap cleanup_payload EXIT
# Write a bash wrapper that invokes node on the installed bundle.
cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json"
npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING"
cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist"
if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then
echo "Error: installed CLI failed its start-up check" >&2
exit 1
fi
PAYLOAD_BACKUP=""
if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then
PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")"
rmdir "$PAYLOAD_BACKUP"
mv -- "$LIB_DIR" "$PAYLOAD_BACKUP"
fi
if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then
if [[ -n "$PAYLOAD_BACKUP" ]]; then
mv -- "$PAYLOAD_BACKUP" "$LIB_DIR"
fi
echo "Error: failed to install the CLI files at $LIB_DIR" >&2
exit 1
fi
PAYLOAD_STAGING=""
if [[ -n "$PAYLOAD_BACKUP" ]]; then
rm -rf -- "$PAYLOAD_BACKUP"
fi
trap - EXIT
# Write a bash wrapper that invokes Node.js on the installed payload.
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 CLI files: $LIB_DIR"
echo "[INFO] Installed binary: $LIVESYNC_BIN"
# ── Write systemd unit ───────────────────────────────────────────────────────
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
sleep 1
# shellcheck disable=SC2086
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
echo "Error: livesync-cli service did not remain active after startup." >&2
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
exit 1
fi
echo ""
echo "[Done] livesync-cli service installed and started."
echo ""
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const deploySourceDirectory = dirname(fileURLToPath(import.meta.url));
const temporaryDirectories: string[] = [];
type InstallerFixture = {
cliDirectory: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
installerPath: string;
npmCallLog: string;
repositoryRoot: string;
systemctlCallLog: string;
vaultDirectory: string;
};
async function writeExecutable(path: string, content: string): Promise<void> {
await writeFile(path, `${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-"));
temporaryDirectories.push(temporaryDirectory);
const repositoryRoot = join(temporaryDirectory, "repository");
const cliDirectory = join(repositoryRoot, "src", "apps", "cli");
const deployDirectory = join(cliDirectory, "deploy");
const distDirectory = join(cliDirectory, "dist");
const fakeBinDirectory = join(temporaryDirectory, "fake-bin");
const homeDirectory = join(temporaryDirectory, "home");
const vaultDirectory = join(temporaryDirectory, "vault");
const npmCallLog = join(temporaryDirectory, "npm-calls.log");
const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log");
await Promise.all([
mkdir(deployDirectory, { recursive: true }),
mkdir(distDirectory, { recursive: true }),
mkdir(fakeBinDirectory, { recursive: true }),
mkdir(homeDirectory, { recursive: true }),
mkdir(vaultDirectory, { recursive: true }),
]);
await Promise.all([
copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")),
copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")),
writeFile(
join(repositoryRoot, "package.json"),
JSON.stringify({ private: true, workspaces: ["src/apps/*"] }),
"utf8"
),
writeFile(
join(cliDirectory, "package.json"),
JSON.stringify({
name: "self-hosted-livesync-cli",
private: true,
version: "0.0.0",
dependencies: { "fixture-runtime-dependency": "1.0.0" },
}),
"utf8"
),
writeFile(
join(distDirectory, "index.cjs"),
'const chunk = require("./chunk.cjs");\n' +
'const dependency = require("fixture-runtime-dependency");\n' +
"process.stdout.write(`${chunk}:${dependency}\\n`);\n",
"utf8"
),
writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"),
]);
await writeExecutable(
join(fakeBinDirectory, "npm"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"',
'prefix=""',
"expect_prefix=0",
'for argument in "$@"; do',
' if [[ "$expect_prefix" -eq 1 ]]; then',
' prefix="$argument"',
" expect_prefix=0",
' elif [[ "$argument" == "--prefix" ]]; then',
" expect_prefix=1",
" fi",
"done",
'if [[ -n "$prefix" ]]; then',
' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"',
" printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"",
"fi",
].join("\n")
);
await writeExecutable(
join(fakeBinDirectory, "systemctl"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"',
'if [[ " $* " == *" is-active "* ]]; then',
' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]',
" exit",
"fi",
'if [[ " $* " == *" status "* ]]; then',
" printf '%s\\n' \"fixture service status\"",
"fi",
].join("\n")
);
await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"));
return {
cliDirectory,
environment: {
...process.env,
FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0",
HOME: homeDirectory,
NPM_CALL_LOG: npmCallLog,
PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
SYSTEMCTL_CALL_LOG: systemctlCallLog,
},
homeDirectory,
installerPath: join(deployDirectory, "install.sh"),
npmCallLog,
repositoryRoot,
systemctlCallLog,
vaultDirectory,
};
}
function runInstaller(fixture: InstallerFixture) {
return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], {
encoding: "utf8",
env: fixture.environment,
});
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe.skipIf(process.platform === "win32")("CLI systemd installer", () => {
it("installs a runnable CLI independently of the source repository", async () => {
const fixture = await createInstallerFixture(true);
const installation = runInstaller(fixture);
expect(installation.error).toBeUndefined();
expect(installation.status, installation.stderr).toBe(0);
expect(installation.stdout).toContain("[Done] livesync-cli service installed and started.");
const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli");
const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs");
const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service");
expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")');
expect(await readFile(installedUnit, "utf8")).toContain("Type=exec");
await rm(fixture.repositoryRoot, { recursive: true });
const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment });
expect(command.error).toBeUndefined();
expect(command.status, command.stderr).toBe(0);
expect(command.stdout).toBe("chunk-ready:dependency-ready\n");
const npmCalls = await readFile(fixture.npmCallLog, "utf8");
expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`);
expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`);
expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/);
const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8");
expect(systemctlCalls).toContain("--user enable --now livesync-cli");
expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli");
});
it("does not report success when the service fails to remain active", async () => {
const fixture = await createInstallerFixture(false);
const installation = runInstaller(fixture);
const combinedOutput = `${installation.stdout}\n${installation.stderr}`;
expect(installation.error).toBeUndefined();
expect(installation.status).not.toBe(0);
expect(combinedOutput).toContain("service did not remain active after startup");
expect(combinedOutput).not.toContain("[Done]");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=exec
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
Restart=on-failure
RestartSec=10
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.21-cli",
"version": "1.0.23-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.21-webapp",
"version": "1.0.23-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.21-webpeer",
"version": "1.0.23-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+3 -2
View File
@@ -3,6 +3,7 @@ import {
CAPABILITY_NOT_APPLICABLE,
CENTRAL_REMOTE_REPLICATION_READINESS,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
defineReplicatorProviderDefinitions,
supportedOpenReplicationContinuous,
@@ -72,7 +73,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
return await runOneShotWithOutcome(
instance,
setting,
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
@@ -85,7 +86,7 @@ const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (ins
return await runOneShotWithOutcome(
instance,
setting,
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
@@ -5,6 +5,7 @@ import {
CAPABILITY_SUPPORT_KINDS,
NO_INTERACTION,
REPLICATION_COMPLETED,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -135,6 +136,7 @@ describe("central Replicator provider definitions", () => {
await expect(
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
})
).resolves.toBe(REPLICATION_COMPLETED);
@@ -149,6 +151,40 @@ describe("central Replicator provider definitions", () => {
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
});
it.each([
["CouchDB", REMOTE_COUCHDB],
["Object Storage", REMOTE_MINIO],
] as const)("maps %s progress presentation independently of recovery authority", async (label, remoteType) => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const definition = definitions.get(remoteType)!;
if (definition.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
throw new Error(`${label} OneShot is unavailable`);
}
const setting = Object.assign(createNewVaultSettings(), { remoteType });
const openOneShotReplicationWithOutcome = vi.fn(async () => REPLICATION_COMPLETED);
const instance = {
initializeDatabaseForReplication: vi.fn(async () => true),
openReplication: vi.fn(async () => true),
terminateSync: vi.fn(),
closeReplication: vi.fn(),
openOneShotReplicationWithOutcome,
};
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(1, setting, false);
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
});
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const couchDB = definitions.get(REMOTE_COUCHDB)!;
@@ -172,6 +208,7 @@ describe("central Replicator provider definitions", () => {
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
@@ -201,6 +238,7 @@ describe("central Replicator provider definitions", () => {
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
+5 -1
View File
@@ -24,7 +24,10 @@
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
export let plugin: ObsidianLiveSyncPlugin;
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
@@ -107,6 +110,7 @@
async function replicate() {
await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}
+5 -1
View File
@@ -4,7 +4,10 @@ import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
@@ -15,6 +18,7 @@ export class ModuleBasicMenu extends AbstractModule {
callback: async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
},
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
@@ -124,6 +128,19 @@ describe("ModuleBasicMenu command palette", () => {
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
await fixture.getCommand("livesync-replicate").callback?.();
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
@@ -2,7 +2,10 @@ import { addIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import type { LiveSyncCore } from "@/main.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Obsidian specific menu commands.
export class ModuleObsidianMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
@@ -20,6 +23,7 @@ export class ModuleObsidianMenu extends AbstractModule {
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}).addClass("livesync-ribbon-replicate");
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
describe("ModuleObsidianMenu ribbon", () => {
it("retains visible progress and full interaction authority", async () => {
let runRibbonAction: (() => Promise<void>) | undefined;
const addClass = vi.fn();
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
registerProtocolHandler: vi.fn(),
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
runRibbonAction = callback;
return { addClass };
}),
},
replication: { replicateUserInitiated },
};
const module = new ModuleObsidianMenu({ _services: services, services } as never);
await module._everyOnloadStart();
await runRibbonAction?.();
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
});
});
+9
View File
@@ -291,6 +291,15 @@ export async function adjustSettingToRemote(
return true;
}
if (operation === "rebuild") {
// An overwrite makes this device authoritative for both the Vault contents and the
// shared synchronisation settings. The remote lookup above remains a connection
// preflight, but settings from the database which is about to be replaced must not
// overwrite intentional local changes such as enabling E2EE.
log("Rebuild will use this device's synchronisation settings.", LOG_LEVEL_NOTICE);
return true;
}
const remoteTweaks = remoteResult.values;
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config.
+37 -1
View File
@@ -1149,6 +1149,33 @@ describe("Red Flag Feature", () => {
});
describe("Remote configuration adjustment", () => {
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
const host = createHostMock();
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
encrypt: true,
passphrase: "local-encryption-passphrase",
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({
...TweakValuesShouldMatchedTemplate,
encrypt: false,
})
);
const result = await adjustSettingToRemote(
host as any,
createLoggerMock(),
host.mocks.setting.currentSettings(),
"rebuild"
);
expect(result).toBe(true);
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase");
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
});
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
const host = createHostMock();
const config = { preventFetchingConfig: true } as any;
@@ -1855,8 +1882,15 @@ describe("Red Flag Feature", () => {
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
const host = createHostMock();
const log = createLoggerMock();
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
encrypt: true,
passphrase: "local-encryption-passphrase",
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({ customChunkSize: 1 })
availableRemoteTweaks({
...TweakValuesShouldMatchedTemplate,
encrypt: false,
})
);
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
@@ -1868,6 +1902,8 @@ describe("Red Flag Feature", () => {
await Promise.resolve(eventHandler());
await new Promise((resolve) => setTimeout(resolve, 10));
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
});
@@ -5,6 +5,7 @@ import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_PROGRESS_PRESENTATIONS,
type ReplicatorInstance,
type ReplicationFailureRequest,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -52,11 +53,11 @@ function canMarkRemoteResolved(replicator: ReplicatorInstance): replicator is Re
*/
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
async function reconcileCleanedRemote(
showMessage: boolean,
showProgress: boolean,
setting: ObsidianLiveSyncSettings,
expectedContext: ReplicationFailureRequest["context"]
) {
Logger("The remote database has been cleaned.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
await skipIfDuplicated("cleanup", async () => {
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
const message = `The remote database has been cleaned up.
@@ -100,7 +101,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
localDatabase.clearCaches();
const replicated = await context.services.replicator.runFiniteReplicationActivity(
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
() => replicator.openOneShotReplication(setting, showProgress, false, "sync", true),
{ label: "replication" }
);
if (replicated) {
@@ -110,12 +111,12 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
await replicator.markRemoteResolved(setting);
Logger(
"The local database has been cleaned up.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
} else {
Logger(
"Replication has been cancelled. Please try it again.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
}
} finally {
@@ -128,14 +129,15 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
}
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
const { context: failedContext, interaction, outcome, setting, showMessage } = request;
if (!showMessage) {
const { context: failedContext, interaction, outcome, progressPresentation, setting } = request;
const showProgress = progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE;
if (interaction.kind === "forbidden") {
// Automatic requests may report the failure, but must not enter
// tweak, lock, fetch, unlock, or cleanup dialogues.
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
return false;
}
if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false;
if (!interaction.permissions.failureRecovery) return false;
const recovery = outcome.recoveryHint;
if (!recovery) return false;
@@ -169,7 +171,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
usesLegacyIndexedDBAdapter(setting)
) {
await reconcileCleanedRemote(showMessage, setting, failedContext);
await reconcileCleanedRemote(showProgress, setting, failedContext);
return false;
}
@@ -4,6 +4,7 @@ import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction }
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -43,7 +44,7 @@ describe("central compatibility recovery", () => {
context: { provider: {}, replicator: {} },
setting: {},
outcome: replicationFailed(new Error("provider failed")),
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: NO_INTERACTION,
} as never)
).resolves.toBe(false);
@@ -93,7 +94,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: NO_INTERACTION,
} as never);
expect(askResolvingMismatched).not.toHaveBeenCalled();
@@ -102,7 +103,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: {
kind: "permitted",
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
@@ -114,7 +115,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
@@ -158,7 +159,7 @@ describe("central compatibility recovery", () => {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
preferredTweakValue: { customChunkSize: 60 },
}),
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
@@ -196,7 +197,7 @@ describe("central compatibility recovery", () => {
outcome: replicationFailed(new Error("locked"), {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
}),
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
@@ -1,12 +1,15 @@
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
deleteCouchDbDatabase,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
@@ -28,7 +31,8 @@ import {
continueWithoutRemoteSettings,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { dismissConfigDoctorIfShown } from "../runner/upgradeWorkflow.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
@@ -44,6 +48,10 @@ const captures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual",
} as const;
const e2eeRebuildCaptures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual-e2ee-rebuild",
} as const;
type RunnerContext = {
binary: string;
@@ -111,9 +119,7 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
await withObsidianPage(port, async (page) => {
const method = modalByTitle(page, "Connection Method");
await selectRadioOption(method, "Configure a remote manually");
await method
.getByRole("button", { name: "Proceed with manual configuration" })
.click({ timeout: uiTimeoutMs });
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -246,6 +252,112 @@ async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; c
});
}
async function assertPersistedE2EE(vault: TemporaryVault): Promise<void> {
const persisted = JSON.parse(
await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8")
) as {
encrypt?: unknown;
encryptedPassphrase?: unknown;
passphrase?: unknown;
};
assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled.");
assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text.");
if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) {
throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase.");
}
}
async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise<void> {
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
const tweakValues = milestone.tweak_values;
if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) {
throw new Error("The existing CouchDB milestone did not contain synchronisation settings.");
}
const preferred = (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED];
if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) {
throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings.");
}
await putCouchDbDocument(context.couchDb, context.dbName, {
...milestone,
tweak_values: {
...tweakValues,
[DEVICE_ID_PREFERRED]: {
...(preferred as Record<string, unknown>),
encrypt: false,
},
},
});
}
async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise<void> {
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
const tweakValues = milestone.tweak_values;
const preferred =
typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues)
? (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED]
: undefined;
const encrypt =
typeof preferred === "object" && preferred !== null && !Array.isArray(preferred)
? (preferred as Record<string, unknown>).encrypt
: undefined;
assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`);
}
async function scheduleRemoteOverwrite(port: number): Promise<void> {
await dismissConfigDoctorIfShown(port);
await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const maintenance = await settingsNavigator.openPage("Maintenance");
const overwrite = maintenance
.locator(".setting-item")
.filter({ hasText: "Overwrite Server Data with This Device's Files" });
await overwrite
.getByRole("button", { name: "Schedule and Restart", exact: true })
.click({ timeout: uiTimeoutMs });
});
}
async function assertRemoteEntryEncrypted(
context: RunnerContext,
entry: { id: string; path: string; children: string[] },
plaintextPath: string,
plaintext: string
): Promise<void> {
const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
const serialisedMetadata = JSON.stringify(remoteMetadata);
if (
!remoteMetadata._id.startsWith("f:") ||
typeof remoteMetadata.path !== "string" ||
!remoteMetadata.path.startsWith("/\\:") ||
remoteMetadata.path === entry.path ||
serialisedMetadata.includes(plaintextPath) ||
!Array.isArray(remoteMetadata.children) ||
remoteMetadata.children.length !== 0 ||
remoteMetadata.mtime !== 0 ||
remoteMetadata.ctime !== 0 ||
remoteMetadata.size !== 0
) {
throw new Error("The directly fetched CouchDB Metadata document did not protect its properties.");
}
const childId = entry.children[0];
if (!childId) {
throw new Error("The local E2EE test entry did not reference a Chunk document.");
}
if (!childId.startsWith("h:+")) {
throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`);
}
const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId);
assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted.");
if (
typeof remoteChunk.data !== "string" ||
remoteChunk.data === plaintext ||
remoteChunk.data.includes(plaintext)
) {
throw new Error("The directly fetched CouchDB Chunk contained readable Vault content.");
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -288,11 +400,37 @@ async function main(): Promise<void> {
1,
"Manual CouchDB setup did not persist exactly one remote profile."
);
await assertPersistedE2EE(vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, entry);
} catch (error) {
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
await setRemotePreferredE2EEDisabled(context);
await assertRemotePreferredE2EE(context, false);
session = await startUnconfiguredSession(context, vaultA);
try {
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
screenshots.push(
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
);
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await assertPersistedE2EE(vaultA);
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await waitForRemoteEntry(context, rebuiltEntry);
await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent);
await assertRemotePreferredE2EE(context, true);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
@@ -302,7 +440,7 @@ async function main(): Promise<void> {
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "first-device");
await captureFailure(session, "e2ee-rebuild");
throw error;
} finally {
await stopTrackedSession(context, session);
+32
View File
@@ -16,6 +16,36 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
### Command-line tool
#### Fixed
- The systemd installer now finds the repository root correctly, installs every generated bundle chunk and required production dependency, checks the installed command before activation, and reports success only when the service remains active.
## 1.0.23
2nd September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
- **Sync now** once again keeps routine progress quiet, while still opening recovery dialogues when a decision is required. Repeated OneShot Sync requests received while an earlier attempt is running are now ignored instead of starting overlapping work.
## 1.0.22
1st September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
- **Sync on Startup** now runs an immediate Object Storage synchronisation after start-up or resume, including migrated profiles which retain a Continuous setting that Object Storage cannot use.
- A temporarily unavailable Object Storage synchronisation-parameter read is no longer treated as a missing object and cannot regenerate the shared Security Seed. Flow-specific Security Seed checks also bypass an earlier process-cached result.
- Local database reset and plug-in unload now retire active replication through its owner before closing the database, without reporting a missing active Replicator or describing unload as a database reset.
@@ -26,6 +56,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed
- The P2P Setup connection test no longer interrupts an active P2P room. It observes an active compatible relay binding, blocks a test which would add another relay until P2P is disconnected, and uses a short-lived trial only while P2P is idle.
- User-initiated P2P synchronisation now reports success only after the requested target transfer completes.
- Optional WebApp P2P synchronisation now becomes ready after a successful local-file scan even when CouchDB remains unconfigured; failed preparation is not reported as ready.
- Unattended P2P synchronisation no longer raises Notice-level messages for missing configured targets, authentication rejection, configuration mismatch, or an overlapping transfer. User-initiated operations retain their existing feedback.
- P2P replication failure reasons now survive the JSON RPC boundary instead of reaching the requesting device as an empty object.
+3 -1
View File
@@ -33,5 +33,7 @@
"1.0.18": "1.7.2",
"1.0.19": "1.7.2",
"1.0.20": "1.7.2",
"1.0.21": "1.7.2"
"1.0.21": "1.7.2",
"1.0.22": "1.7.2",
"1.0.23": "1.7.2"
}