mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-08 11:24:06 +03:00
Compare commits
1 Commits
fix_911
...
feat_cli_d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5922186a0e |
@@ -80,6 +80,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 +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
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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: <remote-id>");
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -72,6 +72,14 @@ 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:
|
||||
--interval <N>, -i <N> (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) => {
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
146
src/apps/cli/test/test-remote-commands-linux.sh
Normal file
146
src/apps/cli/test/test-remote-commands-linux.sh
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user