Merge branch '0_25_74' into cli_test_deno

This commit is contained in:
vorotamoroz
2026-06-08 11:29:06 +01:00
14 changed files with 744 additions and 34 deletions
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+17 -2
View File
@@ -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 <path>` / `-V <path>`: (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 <remote-id>`: Export the stored connection string by remote ID.
- `remote-set <remote-id> <connstr>`: Replace the stored connection string by remote ID.
- `remote-activate <remote-id>`: 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 <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
--help, -h Show this help message
Commands:
@@ -332,7 +346,8 @@ Commands:
info <path> Show file metadata including current and past revisions, conflicts, and chunk list
rm <path> Mark file as deleted in local database
resolve <path> <rev> 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:
+185 -12
View File
@@ -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<boolean> {
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<boolean> {
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: <src> <dst>");
}
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: <src> <dst>");
}
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: <src> <dst> <rev>");
}
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: <dst>");
}
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: <src>");
}
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: <src> <rev>");
}
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: <path>");
}
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: <path>");
}
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: <path> <revision-to-keep>");
}
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: <remote-id>");
@@ -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}`);
}
@@ -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);
});
});
});
+10
View File
@@ -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<ServiceContext, any>;
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);
+60 -11
View File
@@ -61,7 +61,7 @@ Commands:
info <path> Show detailed metadata for a file (ID, revision, conflicts, chunks)
rm <path> Mark a file as deleted in local database
resolve <path> <rev> Resolve conflicts by keeping <rev> 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 <name> <connstr>
Add a remote configuration from a connection string
remote-rm <remote-id> Remove a remote configuration by ID
@@ -72,8 +72,18 @@ Commands:
Replace a stored remote connection string by ID
remote-activate <remote-id>
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 <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
(defaults to database-path; allows separate PouchDB and vault dirs)
--interval <N>, -i <N> (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;
+4 -2
View File
@@ -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": {}
@@ -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 "<none>" >&2
exit 1
fi
echo "[PASS] decoupled database/vault E2E tests successfully completed"
@@ -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"
+1 -1
Submodule src/lib updated: 808efe19c8...82e15f2b9d
+3 -2
View File
@@ -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],
+26
View File
@@ -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