From fff6df535fa58d7f0da0bdda51c979cb22f054f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E6=B3=BD=E5=AE=87?= Date: Thu, 28 May 2026 16:44:34 +0800 Subject: [PATCH 01/11] feat(cli): add --vault option for daemon and mirror commands Allow specifying a separate vault directory for .md files, decoupled from the database directory (where PouchDB data lives). - Add --vault/-V flag to CLI options parser - Use vaultPath (or fallback to databasePath) for file system operations - Works with both daemon and mirror commands - Log vault path alongside database path at startup --- src/apps/cli/commands/types.ts | 1 + src/apps/cli/main.ts | 29 ++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 2f5337f..7963996 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -30,6 +30,7 @@ export type CLICommand = export interface CLIOptions { databasePath?: string; + vaultPath?: string; settingsPath?: string; verbose?: boolean; debug?: boolean; diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 1bd1ea5..44ba4e8 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -61,7 +61,7 @@ Commands: info Show detailed metadata for a file (ID, revision, conflicts, chunks) rm Mark a file as deleted in local database resolve Resolve conflicts by keeping and deleting others - mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path) + mirror [vault-path] Mirror database contents to the local file system (vault-path takes precedence over --vault; defaults to vault from --vault / database-path) remote-add Add a remote configuration from a connection string remote-rm Remove a remote configuration by ID @@ -74,6 +74,8 @@ Commands: Activate a stored remote configuration by ID Options: + --vault , -V (daemon/mirror) Path to the vault directory containing .md files + (defaults to database-path; allows separate PouchDB and vault dirs) --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed Examples: @@ -114,6 +116,7 @@ export function parseArgs(): CLIOptions { } let databasePath: string | undefined; + let vaultPath: string | undefined; let settingsPath: string | undefined; let verbose = false; let debug = false; @@ -125,6 +128,16 @@ export function parseArgs(): CLIOptions { for (let i = 0; i < args.length; i++) { const token = args[i]; switch (token) { + case "--vault": + case "-V": { + i++; + if (!args[i]) { + console.error(`Error: Missing value for ${token}`); + process.exit(1); + } + vaultPath = args[i]; + break; + } case "--settings": case "-s": { i++; @@ -198,6 +211,7 @@ export function parseArgs(): CLIOptions { return { databasePath, + vaultPath, settingsPath, verbose, debug, @@ -290,16 +304,17 @@ export async function main() { : path.join(databasePath, SETTINGS_FILE); configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json")); + // Resolve vault path: --vault flag takes priority, otherwise fall back to databasePath + // For daemon mode, enable chokidar file watching so the _changes feed picks up events. + // mirror runs a single full scan and doesn't need continuous watching. + const watchEnabled = options.command === "daemon"; + const vaultPath = options.vaultPath ? path.resolve(options.vaultPath) : databasePath!; + infoLog(`Self-hosted LiveSync CLI`); infoLog(`Database Path: ${databasePath}`); + infoLog(`Vault Path: ${vaultPath}`); infoLog(`Settings: ${settingsPath}`); infoLog(""); - - // For daemon and mirror mode, load ignore rules before the core is constructed so that - // chokidar's ignored option is populated when beginWatch() fires during onLoad(). - const watchEnabled = options.command === "daemon"; - const vaultPath = - options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath; let ignoreRules: IgnoreRules | undefined; if (options.command === "daemon" || options.command === "mirror") { ignoreRules = new IgnoreRules(vaultPath); From be979a3bf144fca8bc09a48bdbee13ad6465f8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E6=B3=BD=E5=AE=87?= Date: Fri, 5 Jun 2026 11:50:23 +0800 Subject: [PATCH 02/11] fix(cli): respect mirror positional arg before --vault flag Per vrtmrz's review feedback, restore the mirror [vault-path] positional argument support with correct priority order: mirror positional arg > --vault flag > databasePath Also update --vault help text and CLI README with the new option. --- src/apps/cli/README.md | 7 ++++++- src/apps/cli/main.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 18f70b7..7adc18c 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -61,6 +61,9 @@ livesync-cli [database-path] [command] [args...] - `database-path`: Path to the directory where `.livesync` folder and `settings.json` are (or will be) located. - Note: In previous versions, this was referred to as the "vault" path. Now it is clearly distinguished from the actual vault (the directory containing your `.md` files). +- `--vault ` / `-V `: (daemon/mirror only) Path to the vault directory containing `.md` files. + - Allows the PouchDB database directory and the actual vault directory to be different locations. + - For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`. ### Commands @@ -312,6 +315,7 @@ Options: --verbose, -v Enable verbose logging --debug, -d Enable debug logging (includes verbose) --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed + --vault , -V (daemon/mirror) Path to vault directory, decoupled from database-path --help, -h Show this help message Commands: @@ -332,7 +336,8 @@ Commands: info Show file metadata including current and past revisions, conflicts, and chunk list rm Mark file as deleted in local database resolve Resolve conflict by keeping the specified revision - mirror [vaultPath] Mirror database contents to the local file system (vaultPath defaults to database-path) + mirror [vaultPath] Mirror database contents to the local file system + (vaultPath positional arg > --vault flag > database-path) ``` Run via npm script: diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 44ba4e8..093b8f5 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -304,11 +304,17 @@ export async function main() { : path.join(databasePath, SETTINGS_FILE); configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json")); - // Resolve vault path: --vault flag takes priority, otherwise fall back to databasePath + // Resolve vault path: mirror positional argument takes priority, + // then --vault flag, otherwise fall back to databasePath. // For daemon mode, enable chokidar file watching so the _changes feed picks up events. // mirror runs a single full scan and doesn't need continuous watching. const watchEnabled = options.command === "daemon"; - const vaultPath = options.vaultPath ? path.resolve(options.vaultPath) : databasePath!; + const vaultPath = + options.command === "mirror" && options.commandArgs[0] + ? path.resolve(options.commandArgs[0]) + : options.vaultPath + ? path.resolve(options.vaultPath) + : databasePath!; infoLog(`Self-hosted LiveSync CLI`); infoLog(`Database Path: ${databasePath}`); From 5922186a0eaa76c1c391a01af66347f959f99e24 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 04:23:22 +0000 Subject: [PATCH 03/11] feat: Added new remote management commands --- src/apps/cli/README.md | 12 +- src/apps/cli/commands/runCommand.ts | 176 +++++++++++++++++- src/apps/cli/commands/runCommand.unit.spec.ts | 163 ++++++++++++++++ src/apps/cli/commands/types.ts | 8 + src/apps/cli/main.ts | 18 +- src/apps/cli/package.json | 6 +- .../cli/test/test-remote-commands-linux.sh | 146 +++++++++++++++ updates.md | 6 + 8 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 src/apps/cli/test/test-remote-commands-linux.sh diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 18f70b7..7754271 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -80,6 +80,10 @@ livesync-cli [database-path] [command] [args...] - `remote-export `: Export the stored connection string by remote ID. - `remote-set `: Replace the stored connection string by remote ID. - `remote-activate `: Activate a remote configuration by ID. +- `mark-resolved [remote-id]`: Resolve remote synchronisation status. +- `unlock-remote [remote-id]`: Unlock the remote database. +- `lock-remote [remote-id]`: Lock the remote database. +- `remote-status [remote-id]`: Show remote database status. - `init-settings [file]`: Create a default settings file. ### Examples @@ -259,13 +263,19 @@ livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm / # Resolve conflict by keeping a specific revision livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef -# Add/list/activate/remove remote configurations +# Add, list, activate, and remove remote configurations livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db" livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123 livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret" livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123 livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123 + +# Lock, unlock, resolve, and view status of remote database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-status remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json lock-remote remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json mark-resolved remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json unlock-remote remote-abc123 ``` ### Configuration diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index cbcb955..c82f1c3 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -2,7 +2,14 @@ import * as fs from "fs/promises"; import * as path from "path"; import { decodeSettingsFromSetupURI } from "@lib/API/processSetting"; import { configURIBase } from "@lib/common/models/shared.const"; -import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types"; +import { + DEFAULT_SETTINGS, + MILESTONE_DOCID, + type FilePathWithPrefix, + type ObsidianLiveSyncSettings, + REMOTE_COUCHDB, + REMOTE_MINIO, +} from "@lib/common/types"; import { ConnectionStringParser } from "@lib/common/ConnectionString"; import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig"; import { stripAllPrefixes } from "@lib/string_and_binary/path"; @@ -16,6 +23,51 @@ function redactConnectionString(uri: string): string { return uri.replace(/\/\/([^@/]+)@/u, "//***@"); } +async function verifyRemoteState( + core: CLICommandContext["core"], + settings: ObsidianLiveSyncSettings +): Promise { + const replicator = core.services.replicator.getActiveReplicator(); + if (!replicator) { + process.stderr.write("[Verification] No active replicator found\n"); + return false; + } + + if (!replicator.nodeid) { + await replicator.initializeDatabaseForReplication(); + } + + try { + let milestone: any; + if (settings.remoteType === REMOTE_COUCHDB) { + const dbRet = await (replicator as any).connectRemoteCouchDBWithSetting(settings, false, true); + if (typeof dbRet === "string") { + process.stderr.write(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`); + return false; + } + milestone = await dbRet.db.get(MILESTONE_DOCID); + } else if (settings.remoteType === REMOTE_MINIO) { + milestone = await (replicator as any).client.downloadJson("_00000000-milestone.json"); + } + + if (milestone) { + const isLocked = !!milestone.locked; + const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid); + process.stderr.write(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`); + process.stderr.write( + `[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n` + ); + return true; + } else { + process.stderr.write("[Verification] Milestone document not found on remote.\n"); + return false; + } + } catch (e: any) { + process.stderr.write(`[Verification] Failed to fetch milestone document: ${e?.message || e}\n`); + return false; + } +} + export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise { const { databasePath, core, settingsPath } = context; @@ -646,7 +698,6 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext console.error(`[Command] remote-set ${id}`); return true; } - if (options.command === "remote-activate") { if (options.commandArgs.length < 1) { throw new Error("remote-activate requires one argument: "); @@ -676,5 +727,126 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext return true; } + if (options.command === "mark-resolved") { + const id = options.commandArgs[0]?.trim(); + if (id) { + let switched = false; + await core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, false); + + if (!switched) { + process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); + return false; + } + + await core.services.control.applySettings(); + } + + console.error(`[Command] mark-resolved${id ? ` ${id}` : ""}`); + await core.services.replication.markResolved(); + const settings = core.services.setting.currentSettings(); + await verifyRemoteState(core, settings); + return true; + } + + if (options.command === "unlock-remote") { + const id = options.commandArgs[0]?.trim(); + if (id) { + let switched = false; + await core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, false); + + if (!switched) { + process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); + return false; + } + + await core.services.control.applySettings(); + } + + console.error(`[Command] unlock-remote${id ? ` ${id}` : ""}`); + await core.services.replication.markUnlocked(); + const settings = core.services.setting.currentSettings(); + await verifyRemoteState(core, settings); + return true; + } + + if (options.command === "lock-remote") { + const id = options.commandArgs[0]?.trim(); + if (id) { + let switched = false; + await core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, false); + + if (!switched) { + process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); + return false; + } + + await core.services.control.applySettings(); + } + + console.error(`[Command] lock-remote${id ? ` ${id}` : ""}`); + await core.services.replication.markLocked(); + const settings = core.services.setting.currentSettings(); + await verifyRemoteState(core, settings); + return true; + } + + if (options.command === "remote-status") { + const id = options.commandArgs[0]?.trim(); + if (id) { + let switched = false; + await core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, false); + + if (!switched) { + process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); + return false; + } + + await core.services.control.applySettings(); + } + + console.error(`[Command] remote-status${id ? ` ${id}` : ""}`); + const replicator = core.services.replicator.getActiveReplicator(); + if (!replicator) { + process.stderr.write("[Error] No active replicator found\n"); + return false; + } + const settings = core.services.setting.currentSettings(); + const status = await replicator.getRemoteStatus(settings); + if (status === false) { + process.stderr.write("[Error] Failed to fetch remote status\n"); + return false; + } + process.stdout.write(JSON.stringify(status, null, 2) + "\n"); + return true; + } + throw new Error(`Unsupported command: ${options.command}`); } diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 204394a..09eb033 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -28,6 +28,34 @@ function createCoreMock() { updater(liveSettings); }), }, + replication: { + markResolved: vi.fn(async () => {}), + markUnlocked: vi.fn(async () => {}), + markLocked: vi.fn(async () => {}), + }, + replicator: { + getActiveReplicator: vi.fn(() => ({ + nodeid: "test-node-id", + initializeDatabaseForReplication: vi.fn(async () => {}), + connectRemoteCouchDBWithSetting: vi.fn(async () => ({ + db: { + get: vi.fn(async (id) => { + if (id.includes("milestone")) { + return { + locked: false, + accepted_nodes: ["test-node-id"], + }; + } + throw new Error("not found"); + }), + }, + })), + getRemoteStatus: vi.fn(async () => ({ + db_name: "test-db", + doc_count: 42, + })), + })), + }, }, serviceModules: { fileHandler: { @@ -572,4 +600,139 @@ describe("runCommand abnormal cases", () => { const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : ""; expect(exported2).toBe(roundTripInput); }); + + describe("mark-resolved and unlock-remote commands", () => { + it("mark-resolved without args runs on active database", async () => { + const core = createCoreMock(); + const result = await runCommand(makeOptions("mark-resolved", []), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).not.toHaveBeenCalled(); + }); + + it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + + const result = await runCommand(makeOptions("mark-resolved", ["r1"]), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + expect(settings.activeConfigurationId).toBe("r1"); + expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); + }); + + it("unlock-remote without args runs on active database", async () => { + const core = createCoreMock(); + const result = await runCommand(makeOptions("unlock-remote", []), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).not.toHaveBeenCalled(); + }); + + it("unlock-remote with remote-id temporarily activates it and runs markUnlocked", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + + const result = await runCommand(makeOptions("unlock-remote", ["r1"]), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + expect(settings.activeConfigurationId).toBe("r1"); + expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); + }); + + it("lock-remote without args runs on active database", async () => { + const core = createCoreMock(); + const result = await runCommand(makeOptions("lock-remote", []), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).not.toHaveBeenCalled(); + }); + + it("lock-remote with remote-id temporarily activates it and runs markLocked", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + + const result = await runCommand(makeOptions("lock-remote", ["r1"]), { + ...context, + core, + }); + expect(result).toBe(true); + expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + expect(settings.activeConfigurationId).toBe("r1"); + expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); + }); + + it("remote-status without args outputs status of active remote configuration", async () => { + const core = createCoreMock(); + const stdout = captureStdout(); + const result = await runCommand(makeOptions("remote-status", []), { + ...context, + core, + }); + expect(result).toBe(true); + const fullOutput = stdout.spy.mock.calls.map((c) => c[0]).join(""); + const parsedStatus = JSON.parse(fullOutput); + expect(parsedStatus.db_name).toBe("test-db"); + expect(parsedStatus.doc_count).toBe(42); + }); + + it("remote-status with remote-id temporarily activates it and outputs status", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteConfigurations.r1 = { + id: "r1", + name: "R1", + uri: "sls+https://example.com/db1", + isEncrypted: false, + }; + const stdout = captureStdout(); + const result = await runCommand(makeOptions("remote-status", ["r1"]), { + ...context, + core, + }); + expect(result).toBe(true); + const fullOutput = stdout.spy.mock.calls.map((c) => c[0]).join(""); + const parsedStatus = JSON.parse(fullOutput); + expect(parsedStatus.db_name).toBe("test-db"); + expect(parsedStatus.doc_count).toBe(42); + expect(settings.activeConfigurationId).toBe("r1"); + expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); + }); + }); }); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 2f5337f..6fd41f4 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -26,6 +26,10 @@ export type CLICommand = | "remote-export" | "remote-set" | "remote-activate" + | "mark-resolved" + | "unlock-remote" + | "lock-remote" + | "remote-status" | "init-settings"; export interface CLIOptions { @@ -79,5 +83,9 @@ export const VALID_COMMANDS = new Set([ "remote-export", "remote-set", "remote-activate", + "mark-resolved", + "unlock-remote", + "lock-remote", + "remote-status", "init-settings", ] as const); diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 1bd1ea5..ed6e7cc 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -72,6 +72,14 @@ Commands: Replace a stored remote connection string by ID remote-activate Activate a stored remote configuration by ID + mark-resolved [remote-id] + Resolve remote synchronisation status + unlock-remote [remote-id] + Unlock remote database + lock-remote [remote-id] + Lock remote database + remote-status [remote-id] + Show remote database status Options: --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed @@ -100,6 +108,10 @@ Examples: livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket" livesync-cli ./my-database remote-activate remote-abc123 livesync-cli ./my-database remote-rm remote-abc123 + livesync-cli ./my-database mark-resolved remote-abc123 + livesync-cli ./my-database unlock-remote remote-abc123 + livesync-cli ./my-database lock-remote remote-abc123 + livesync-cli ./my-database remote-status remote-abc123 livesync-cli init-settings ./data.json livesync-cli ./my-database --verbose `); @@ -251,7 +263,11 @@ export async function main() { options.command === "p2p-peers" || options.command === "info" || options.command === "rm" || - options.command === "resolve"; + options.command === "resolve" || + options.command === "mark-resolved" || + options.command === "unlock-remote" || + options.command === "lock-remote" || + options.command === "remote-status"; const infoLog = avoidStdoutNoise ? console.error : console.log; if (options.debug) { setGlobalLogFunction((msg, level) => { diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 18768a9..07337bc 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -25,16 +25,18 @@ "test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh", "test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh", "test:e2e:mirror": "bash test/test-mirror-linux.sh", + "test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh", "pretest:e2e:all": "npm run build", - "test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p", + "test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:remote-commands", "pretest:e2e:docker:all": "npm run build:docker", "test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh", "test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh", "test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh", + "test:e2e:docker:remote-commands": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-remote-commands-linux.sh", "test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh", "test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh", "test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh", - "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror" + "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror && npm run test:e2e:docker:remote-commands" }, "dependencies": {}, "devDependencies": {} diff --git a/src/apps/cli/test/test-remote-commands-linux.sh b/src/apps/cli/test/test-remote-commands-linux.sh new file mode 100644 index 0000000..67e7348 --- /dev/null +++ b/src/apps/cli/test/test-remote-commands-linux.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Test: CLI remote management commands: remote-status, unlock-remote, and mark-resolved. +# +# Scenario: +# 1. Start CouchDB, create a test database, and perform an initial sync. +# 2. Run remote-status and assert that the output contains the database name in JSON format. +# 3. Lock the remote database milestone manually using curl, verify status, and run unlock-remote. +# Assert that the output of unlock-remote contains the unlocked verification status. +# 4. Run mark-resolved and verify it succeeds. +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}" +TEST_ENV_FILE="${TEST_ENV_FILE:-$CLI_DIR/.test.env}" +cli_test_init_cli_cmd + +if [[ ! -f "$TEST_ENV_FILE" ]]; then + echo "[ERROR] test env file not found: $TEST_ENV_FILE" >&2 + exit 1 +fi + +set -a +source "$TEST_ENV_FILE" +set +a + +DB_SUFFIX="$(date +%s)-$RANDOM" + +COUCHDB_URI="${hostname%/}" +COUCHDB_DBNAME="${dbname}-remotes-${DB_SUFFIX}" +COUCHDB_USER="${username:-}" +COUCHDB_PASSWORD="${password:-}" + +if [[ -z "$COUCHDB_URI" || -z "$COUCHDB_USER" || -z "$COUCHDB_PASSWORD" ]]; then + echo "[ERROR] COUCHDB_URI, COUCHDB_USER, and COUCHDB_PASSWORD are required" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-remote-cmds.XXXXXX")" +VAULT_DIR="$WORK_DIR/vault" +SETTINGS_FILE="$WORK_DIR/settings.json" +mkdir -p "$VAULT_DIR" + +cleanup() { + local exit_code=$? + cli_test_stop_couchdb + rm -rf "$WORK_DIR" + exit "$exit_code" +} +trap cleanup EXIT + +if [[ "$RUN_BUILD" == "1" ]]; then + echo "[INFO] building CLI" + npm run build +fi + +echo "[INFO] starting CouchDB and creating test database: $COUCHDB_DBNAME" +cli_test_start_couchdb "$COUCHDB_URI" "$COUCHDB_USER" "$COUCHDB_PASSWORD" "$COUCHDB_DBNAME" + +echo "[INFO] preparing settings" +cli_test_init_settings_file "$SETTINGS_FILE" +echo ".." +cli_test_apply_couchdb_settings "$SETTINGS_FILE" "$COUCHDB_URI" "$COUCHDB_USER" "$COUCHDB_PASSWORD" "$COUCHDB_DBNAME" 1 +echo "..." + +echo "[INFO] initial sync to create milestone document" +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" sync >/dev/null + +MILESTONE_ID="_local/obsydian_livesync_milestone" +MILESTONE_URL="${COUCHDB_URI}/${COUCHDB_DBNAME}/${MILESTONE_ID}" + +update_milestone() { + local locked="$1" + local accepted_nodes="$2" + local current + current="$(cli_test_curl_json --user "${COUCHDB_USER}:${COUCHDB_PASSWORD}" "$MILESTONE_URL")" + local updated + updated="$(node -e ' +const doc = JSON.parse(process.argv[1]); +doc.locked = process.argv[2] === "true"; +doc.accepted_nodes = JSON.parse(process.argv[3]); +process.stdout.write(JSON.stringify(doc)); +' "$current" "$locked" "$accepted_nodes")" + cli_test_curl_json -X PUT \ + --user "${COUCHDB_USER}:${COUCHDB_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "$updated" \ + "$MILESTONE_URL" >/dev/null +} + +CMD_LOG="$WORK_DIR/cmd.log" + +echo "[CASE] remote-status outputs valid JSON with CouchDB details" +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" remote-status >"$CMD_LOG" 2>&1 + +cli_test_assert_contains "$(cat "$CMD_LOG")" \ + "\"db_name\": \"$COUCHDB_DBNAME\"" \ + "remote-status should return JSON containing db_name" + +echo "[PASS] remote-status verified" + +echo "[CASE] lock-remote locks and verifies state" +# Run lock-remote and verify output contains verification message +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" lock-remote >"$CMD_LOG" 2>&1 + +cli_test_assert_contains "$(cat "$CMD_LOG")" \ + "[Verification] Remote Database: LOCKED" \ + "lock-remote output should show that the remote database is locked" + +echo "[PASS] lock-remote verified" + +echo "[CASE] unlock-remote unlocks and verifies state" +# Manually lock milestone +update_milestone "true" "[]" + +# Run unlock-remote and verify output contains verification message +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" unlock-remote >"$CMD_LOG" 2>&1 + +cli_test_assert_contains "$(cat "$CMD_LOG")" \ + "[Verification] Remote Database: UNLOCKED" \ + "unlock-remote output should contain verification status" + +echo "[PASS] unlock-remote verified" + +echo "[CASE] mark-resolved resolves and verifies state" +# Manually lock milestone +update_milestone "true" "[]" + +# Run mark-resolved and verify output contains verification message +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mark-resolved >"$CMD_LOG" 2>&1 + +cli_test_assert_contains "$(cat "$CMD_LOG")" \ + "[Verification] Remote Database: LOCKED" \ + "mark-resolved output should show that the remote database remains locked" + +cli_test_assert_contains "$(cat "$CMD_LOG")" \ + "ACCEPTED" \ + "mark-resolved output should show that the current device node is accepted" + +echo "[PASS] mark-resolved verified" + +echo "[ALL PASS] All remote CLI commands verified successfully" diff --git a/updates.md b/updates.md index ba9a64b..bb70f7d 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,12 @@ 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. +## Only CLI + +8th June, 2026 +- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. + + ## 0.25.73 4th June, 2026 From 8cbb233df7af3a9abba8d5404fbdbc3b16372a41 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 09:04:12 +0100 Subject: [PATCH 04/11] fixed: fix automatic merging behaviour --- src/lib | 2 +- updates.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib b/src/lib index 76d9167..337f3c1 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 76d91674c235c1ccf991a14802c737e82e144ef1 +Subproject commit 337f3c1c84ae980c6dfa62ec8264f2a5e4b832c5 diff --git a/updates.md b/updates.md index ba9a64b..b79f5cf 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,14 @@ 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. +## Unreleased + +8th June, 2026 + +### Fixed + +- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). + ## 0.25.73 4th June, 2026 From 85787eddb381798bdfae6d7acf4667c6aac2092b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 09:24:59 +0100 Subject: [PATCH 05/11] fixed: ignore hidden files completely if hidden file sync disabled --- src/lib | 2 +- updates.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib b/src/lib index 76d9167..f380e53 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 76d91674c235c1ccf991a14802c737e82e144ef1 +Subproject commit f380e536cb9f86dcd951f0c0ff5c133594db3792 diff --git a/updates.md b/updates.md index ba9a64b..e8851d3 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,14 @@ 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. +## Unreleased + +8th June, 2026 + +### Fixed + +- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication. + ## 0.25.73 4th June, 2026 From 3acaa7f2697082294b4fc8b6909e71e84d5b882e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 09:26:43 +0100 Subject: [PATCH 06/11] update readme --- updates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/updates.md b/updates.md index e8851d3..9911bea 100644 --- a/updates.md +++ b/updates.md @@ -9,7 +9,7 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid ### Fixed -- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication. +- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan. ## 0.25.73 From cf173caf88dca158194304645cd252e64f7e606d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 10:43:10 +0100 Subject: [PATCH 07/11] 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 23d8a1ecc371f0b669f786eb46771c129225a49a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 10:51:08 +0100 Subject: [PATCH 08/11] update submodule --- src/lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib b/src/lib index f380e53..2eb8938 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit f380e536cb9f86dcd951f0c0ff5c133594db3792 +Subproject commit 2eb8938ed56023b2990a5e5ea4ec5b6fb3400c35 From 34162f747cd7e3e3eb5ecb3bf4bc352e38364c44 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 11:10:37 +0100 Subject: [PATCH 09/11] 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 From 7b9c0b011fcfc392d3ebfc6cb41608b20159a339 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 11:15:13 +0100 Subject: [PATCH 10/11] bump --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- updates.md | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index fbc492e..b9c4588 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "0.25.73", + "version": "0.25.74", "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", diff --git a/package-lock.json b/package-lock.json index a23ad25..178ac93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "0.25.73", + "version": "0.25.74", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "0.25.73", + "version": "0.25.74", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index 8b178cf..b8a62a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-livesync", - "version": "0.25.73", + "version": "0.25.74", "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", diff --git a/updates.md b/updates.md index 3fc1db3..fed04ca 100644 --- a/updates.md +++ b/updates.md @@ -3,7 +3,7 @@ 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. -## Unreleased +## 0.25.74 8th June, 2026 @@ -11,6 +11,7 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid - Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941). - Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). +- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)! ## Only CLI From 9a51c780114b960d1081d4cb55df3261b64b6e5d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 8 Jun 2026 11:18:35 +0100 Subject: [PATCH 11/11] tweak specificity --- styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index 296e515..e478615 100644 --- a/styles.css +++ b/styles.css @@ -296,8 +296,9 @@ body { content: " ❓"; } -.sls-item-invalid-value { - background-color: rgba(var(--background-modifier-error-rgb), 0.3) !important; +.sls-setting .setting-item-control input.sls-item-invalid-value, +.sls-setting .setting-item-control textarea.sls-item-invalid-value { + background-color: rgba(var(--background-modifier-error-rgb), 0.3); } .sls-setting-disabled input[type=text],