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/src/apps/cli/README.md b/src/apps/cli/README.md index 18f70b7..001e19c 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 @@ -80,6 +83,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 +266,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 @@ -312,6 +325,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 +346,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/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index cbcb955..74ee8ab 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,8 +23,54 @@ 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; + const vaultPath = context.vaultPath || databasePath; await core.services.control.activated; if (options.command === "daemon") { @@ -183,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}`); @@ -201,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}`); @@ -224,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) { @@ -281,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( @@ -294,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, @@ -318,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"); @@ -345,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 }[] = []; @@ -377,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; @@ -421,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); } @@ -430,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"); @@ -646,7 +699,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 +728,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..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"; @@ -28,6 +31,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 +603,178 @@ 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 }); + } + }); + }); + + 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..88c6cde 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -26,10 +26,15 @@ export type CLICommand = | "remote-export" | "remote-set" | "remote-activate" + | "mark-resolved" + | "unlock-remote" + | "lock-remote" + | "remote-status" | "init-settings"; export interface CLIOptions { databasePath?: string; + vaultPath?: string; settingsPath?: string; verbose?: boolean; debug?: boolean; @@ -41,6 +46,7 @@ export interface CLIOptions { export interface CLICommandContext { databasePath: string; + vaultPath: string; core: LiveSyncBaseCore; settingsPath: string; originalSyncSettings: Pick< @@ -79,5 +85,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..ed4cb8b 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 @@ -72,8 +72,18 @@ 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: + --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: @@ -100,6 +110,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 `); @@ -114,6 +128,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 +140,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 +223,7 @@ export function parseArgs(): CLIOptions { return { databasePath, + vaultPath, settingsPath, verbose, debug, @@ -251,7 +277,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) => { @@ -290,16 +320,35 @@ export async function main() { : path.join(databasePath, SETTINGS_FILE); configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json")); - infoLog(`Self-hosted LiveSync CLI`); - infoLog(`Database Path: ${databasePath}`); - 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(). + // 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.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath; + options.command === "mirror" && options.commandArgs[0] + ? path.resolve(options.commandArgs[0]) + : options.vaultPath + ? 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}`); + infoLog(`Vault Path: ${vaultPath}`); + infoLog(`Settings: ${settingsPath}`); + infoLog(""); let ignoreRules: IgnoreRules | undefined; if (options.command === "daemon" || options.command === "mirror") { ignoreRules = new IgnoreRules(vaultPath); @@ -504,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/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-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/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/src/lib b/src/lib index 808efe1..82e15f2 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 808efe19c8e94b32a039463d9bcd407550d335e6 +Subproject commit 82e15f2b9d99e0f595b45c864958a657d5d43bac 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], diff --git a/updates.md b/updates.md index ba9a64b..fed04ca 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,32 @@ 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. +## 0.25.74 + +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 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 + +8th June, 2026 + +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 4th June, 2026