mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-10 16:30:15 +00:00
Merge branch '0_25_74' into cli_test_deno
This commit is contained in:
+17
-2
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user