diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index c82f1c3..74ee8ab 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -70,6 +70,7 @@ async function verifyRemoteState( 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") { @@ -235,7 +236,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}`); @@ -253,7 +254,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}`); @@ -276,7 +277,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) { @@ -333,7 +334,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( @@ -346,7 +347,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, @@ -370,7 +371,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"); @@ -397,7 +398,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 }[] = []; @@ -429,7 +430,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; @@ -473,7 +474,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); } @@ -482,7 +483,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 09eb033..3d02951 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"; @@ -601,6 +604,45 @@ describe("runCommand abnormal cases", () => { 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 }); + } + }); + }); + describe("mark-resolved and unlock-remote commands", () => { it("mark-resolved without args runs on active database", async () => { const core = createCoreMock(); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index e4f3186..88c6cde 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -46,6 +46,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 2fd5dcb..ed4cb8b 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -329,8 +329,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}`); @@ -541,7 +553,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/src/lib b/src/lib index 2eb8938..82e15f2 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 2eb8938ed56023b2990a5e5ea4ec5b6fb3400c35 +Subproject commit 82e15f2b9d99e0f595b45c864958a657d5d43bac diff --git a/updates.md b/updates.md index 40902dd..3fc1db3 100644 --- a/updates.md +++ b/updates.md @@ -18,7 +18,15 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid I should also consider the version numbering for the CLI... +### Improved + - Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. +- 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