From cf173caf88dca158194304645cd252e64f7e606d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 10:43:10 +0100 Subject: [PATCH 1/2] feat: decouple the database and vault directories --- src/apps/cli/commands/runCommand.ts | 21 ++--- src/apps/cli/commands/runCommand.unit.spec.ts | 42 ++++++++++ src/apps/cli/commands/types.ts | 1 + src/apps/cli/main.ts | 18 +++- .../cli/test/test-decoupled-vault-linux.sh | 83 +++++++++++++++++++ updates.md | 13 +++ 6 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 src/apps/cli/test/test-decoupled-vault-linux.sh diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index cbcb955..6b41e0d 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -18,6 +18,7 @@ function redactConnectionString(uri: string): string { export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise { const { databasePath, core, settingsPath } = context; + const vaultPath = context.vaultPath || databasePath; await core.services.control.activated; if (options.command === "daemon") { @@ -183,7 +184,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext throw new Error("push requires two arguments: "); } const sourcePath = path.resolve(options.commandArgs[0]); - const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], databasePath); + const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], vaultPath); const sourceData = await fs.readFile(sourcePath); const sourceStat = await fs.stat(sourcePath); console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`); @@ -201,7 +202,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 2) { throw new Error("pull requires two arguments: "); } - const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const destinationPath = path.resolve(options.commandArgs[1]); console.log(`[Command] pull ${sourceDatabasePath} -> ${destinationPath}`); @@ -224,7 +225,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 3) { throw new Error("pull-rev requires three arguments: "); } - const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const destinationPath = path.resolve(options.commandArgs[1]); const rev = options.commandArgs[2].trim(); if (!rev) { @@ -281,7 +282,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 1) { throw new Error("put requires one argument: "); } - const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const content = await readStdinAsUtf8(); console.log(`[Command] put stdin -> ${destinationDatabasePath}`); return await core.serviceModules.databaseFileAccess.storeContent( @@ -294,7 +295,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 1) { throw new Error("cat requires one argument: "); } - const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); console.error(`[Command] cat ${sourceDatabasePath}`); const source = await core.serviceModules.databaseFileAccess.fetch( sourceDatabasePath as FilePathWithPrefix, @@ -318,7 +319,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 2) { throw new Error("cat-rev requires two arguments: "); } - const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const rev = options.commandArgs[1].trim(); if (!rev) { throw new Error("cat-rev requires a non-empty revision"); @@ -345,7 +346,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.command === "ls") { const prefix = options.commandArgs.length > 0 && options.commandArgs[0].trim() !== "" - ? toDatabaseRelativePath(options.commandArgs[0], databasePath) + ? toDatabaseRelativePath(options.commandArgs[0], vaultPath) : ""; const rows: { path: string; line: string }[] = []; @@ -377,7 +378,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 1) { throw new Error("info requires one argument: "); } - const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { if (doc._deleted || doc.deleted) continue; @@ -421,7 +422,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 1) { throw new Error("rm requires one argument: "); } - const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath); + const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); console.error(`[Command] rm ${targetPath}`); return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix); } @@ -430,7 +431,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext if (options.commandArgs.length < 2) { throw new Error("resolve requires two arguments: "); } - const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath) as FilePathWithPrefix; + const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath) as FilePathWithPrefix; const revisionToKeep = options.commandArgs[1].trim(); if (revisionToKeep === "") { throw new Error("resolve requires a non-empty revision-to-keep"); diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 204394a..a05d546 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -1,3 +1,6 @@ +import * as path from "path"; +import * as fs from "fs/promises"; +import * as os from "os"; import * as processSetting from "@lib/API/processSetting"; import { ConnectionStringParser } from "@lib/common/ConnectionString"; import { configURIBase } from "@lib/common/models/shared.const"; @@ -572,4 +575,43 @@ describe("runCommand abnormal cases", () => { const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : ""; expect(exported2).toBe(roundTripInput); }); + + describe("runCommand with decoupled vault path", () => { + it("push resolves target path relative to vaultPath, not databasePath", async () => { + const core = createCoreMock(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-test-")); + const localVaultPath = path.join(tempDir, "vault"); + const localDatabasePath = path.join(tempDir, "db"); + await fs.mkdir(localVaultPath); + await fs.mkdir(localDatabasePath); + + const fileInVault = path.join(localVaultPath, "existing.md"); + await fs.writeFile(fileInVault, "hello", "utf-8"); + + const decoupledContext = { + databasePath: localDatabasePath, + vaultPath: localVaultPath, + settingsPath: path.join(localDatabasePath, ".livesync/settings.json"), + } as any; + + const options = { + command: "push" as const, + commandArgs: [fileInVault, fileInVault], + databasePath: localDatabasePath, + vaultPath: localVaultPath, + }; + + try { + const result = await runCommand(options, { ...decoupledContext, core }); + expect(result).toBe(true); + expect(core.serviceModules.storageAccess.writeFileAuto).toHaveBeenCalledWith( + "existing.md", + expect.any(ArrayBuffer), + expect.any(Object) + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 7963996..1618666 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -42,6 +42,7 @@ export interface CLIOptions { export interface CLICommandContext { databasePath: string; + vaultPath: string; core: LiveSyncBaseCore; settingsPath: string; originalSyncSettings: Pick< diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 093b8f5..d96dd0d 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -313,8 +313,20 @@ export async function main() { options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : options.vaultPath - ? path.resolve(options.vaultPath) - : databasePath!; + ? path.resolve(options.vaultPath) + : databasePath!; + + // Check if vault directory exists + try { + const stat = await fs.stat(vaultPath); + if (!stat.isDirectory()) { + console.error(`Error: Vault path ${vaultPath} is not a directory`); + process.exit(1); + } + } catch (error) { + console.error(`Error: Vault directory ${vaultPath} does not exist`); + process.exit(1); + } infoLog(`Self-hosted LiveSync CLI`); infoLog(`Database Path: ${databasePath}`); @@ -525,7 +537,7 @@ export async function main() { infoLog(""); } - const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings }); + const result = await runCommand(options, { databasePath, vaultPath, core, settingsPath, originalSyncSettings }); if (!result) { console.error(`[Error] Command '${options.command}' failed`); process.exitCode = 1; diff --git a/src/apps/cli/test/test-decoupled-vault-linux.sh b/src/apps/cli/test/test-decoupled-vault-linux.sh new file mode 100644 index 0000000..7131fd5 --- /dev/null +++ b/src/apps/cli/test/test-decoupled-vault-linux.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +cd "$CLI_DIR" +source "$SCRIPT_DIR/test-helpers.sh" +display_test_info + +RUN_BUILD="${RUN_BUILD:-1}" +REMOTE_PATH="${REMOTE_PATH:-test/push-pull-decoupled.txt}" +cli_test_init_cli_cmd + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-test.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +SETTINGS_FILE="${1:-$WORK_DIR/data.json}" + +if [[ "$RUN_BUILD" == "1" ]]; then + echo "[INFO] building CLI..." + npm run build +fi + +echo "[INFO] generating settings from DEFAULT_SETTINGS -> $SETTINGS_FILE" +cli_test_init_settings_file "$SETTINGS_FILE" + +if [[ -n "${COUCHDB_URI:-}" && -n "${COUCHDB_USER:-}" && -n "${COUCHDB_PASSWORD:-}" && -n "${COUCHDB_DBNAME:-}" ]]; then + echo "[INFO] applying CouchDB env vars to generated settings" + cli_test_apply_couchdb_settings "$SETTINGS_FILE" "$COUCHDB_URI" "$COUCHDB_USER" "$COUCHDB_PASSWORD" "$COUCHDB_DBNAME" +else + echo "[WARN] CouchDB env vars are not fully set. push/pull may fail unless generated settings are updated." + cli_test_mark_settings_configured "$SETTINGS_FILE" +fi + +VAULT_DIR="$WORK_DIR/vault" +DB_DIR="$WORK_DIR/db" +mkdir -p "$VAULT_DIR/test" +mkdir -p "$DB_DIR" + +SRC_FILE="$WORK_DIR/push-source.txt" +PULLED_FILE="$WORK_DIR/pull-result.txt" +printf 'push-pull-decoupled-test %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SRC_FILE" + +# 1. Test push command with decoupled vault directory +echo "[INFO] push with decoupled vault -> $REMOTE_PATH" +run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" push "$SRC_FILE" "$REMOTE_PATH" + +# 2. Test pull command with decoupled vault directory +echo "[INFO] pull with decoupled vault <- $REMOTE_PATH" +run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" pull "$REMOTE_PATH" "$PULLED_FILE" + +if cmp -s "$SRC_FILE" "$PULLED_FILE"; then + echo "[PASS] push/pull roundtrip with decoupled vault matched" +else + echo "[FAIL] push/pull roundtrip with decoupled vault mismatch" >&2 + echo "--- source ---" >&2 + cat "$SRC_FILE" >&2 + echo "--- pulled ---" >&2 + cat "$PULLED_FILE" >&2 + exit 1 +fi + +# 3. Clean up pulled file and vault test directory to verify mirror +rm -f "$PULLED_FILE" +rm -rf "$VAULT_DIR/test" + +# 4. Test mirror command with decoupled vault directory +echo "[INFO] mirror with decoupled vault" +run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror + +RESTORED_FILE="$VAULT_DIR/$REMOTE_PATH" +if cmp -s "$SRC_FILE" "$RESTORED_FILE"; then + echo "[PASS] mirror with decoupled vault matched" +else + echo "[FAIL] mirror with decoupled vault mismatch" >&2 + echo "--- source ---" >&2 + cat "$SRC_FILE" >&2 + echo "--- mirrored/restored ---" >&2 + cat "$RESTORED_FILE" 2>/dev/null || echo "" >&2 + exit 1 +fi + +echo "[PASS] decoupled database/vault E2E tests successfully completed" diff --git a/updates.md b/updates.md index ba9a64b..70e606e 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,19 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +## CLI Only + +8th June, 2026 + +### Improved + +- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option. + +### Fixed (preventive) +- Validated that the specified vault path exists and is indeed a directory before starting the CLI. +- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path. + + ## 0.25.73 4th June, 2026 From 34162f747cd7e3e3eb5ecb3bf4bc352e38364c44 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 11:10:37 +0100 Subject: [PATCH 2/2] fix corrupted test, update submodule --- src/apps/cli/commands/runCommand.unit.spec.ts | 3 +++ src/lib | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 577da55..3d02951 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -640,6 +640,9 @@ describe("runCommand abnormal cases", () => { } finally { await fs.rm(tempDir, { recursive: true, force: true }); } + }); + }); + describe("mark-resolved and unlock-remote commands", () => { it("mark-resolved without args runs on active database", async () => { const core = createCoreMock(); diff --git a/src/lib b/src/lib index 2eb8938..82e15f2 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 2eb8938ed56023b2990a5e5ea4ec5b6fb3400c35 +Subproject commit 82e15f2b9d99e0f595b45c864958a657d5d43bac