Compare commits

..
Author SHA1 Message Date
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
9 changed files with 265 additions and 203 deletions
+6 -4
View File
@@ -522,10 +522,12 @@ 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`.
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.
**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.
+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,15 +291,6 @@ 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.
+1 -37
View File
@@ -1149,33 +1149,6 @@ 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;
@@ -1882,15 +1855,8 @@ 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({
...TweakValuesShouldMatchedTemplate,
encrypt: false,
})
availableRemoteTweaks({ customChunkSize: 1 })
);
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
@@ -1902,8 +1868,6 @@ 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,15 +1,12 @@
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";
@@ -31,7 +28,7 @@ import {
continueWithoutRemoteSettings,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
@@ -47,10 +44,6 @@ 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;
@@ -118,7 +111,9 @@ 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 });
@@ -251,111 +246,6 @@ 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 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();
@@ -398,37 +288,11 @@ 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,
@@ -438,7 +302,7 @@ async function main(): Promise<void> {
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "e2ee-rebuild");
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
+2 -2
View File
@@ -12,11 +12,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
### Synchronisation and storage
### Command-line tool
#### 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)
- 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.21