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 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
9 changed files with 496 additions and 48 deletions
+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`).
+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
+1 -1
View File
@@ -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",
+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();
});
@@ -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);
+12
View File
@@ -12,6 +12,18 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
### Synchronisation and storage
#### 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