Migrate LiveSync flows to provider-owned resources

This commit is contained in:
vorotamoroz
2026-08-28 17:54:04 +00:00
parent f7206b1a6e
commit db70b4c2b6
34 changed files with 1830 additions and 365 deletions
+7
View File
@@ -71,6 +71,9 @@ livesync-cli [database-path] [command] [args...]
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
- All remaining commands leave the settings file unchanged by default.
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
- Without this option, those commands return a non-zero exit code when verification fails.
- Invalid arguments, unknown remote IDs, and errors thrown while activating or mutating the remote remain errors with or without this option.
### Commands
@@ -96,6 +99,8 @@ livesync-cli [database-path] [command] [args...]
- `remote-status [remote-id]`: Show remote database status.
- `init-settings [file]`: Create a default settings file.
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
### Examples
```bash
@@ -338,6 +343,8 @@ Options:
--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
--write-settings Write setting changes after a successful command
--compat-remote-admin-exit-zero
Preserve the former zero exit code when remote-administration verification fails
--help, -h Show this help message
Commands:
@@ -0,0 +1,120 @@
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import {
REMOTE_ADMINISTRATION_ACTIONS,
REMOTE_ADMINISTRATION_FAILURE_REASONS,
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
isRemoteAdministrationVerified,
type RemoteAdministrationAction,
type RemoteAdministrationResult,
} from "@vrtmrz/livesync-commonlib/replication";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { writeStderrLine } from "@/apps/cli/cliOutput";
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
const REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
"mark-resolved": REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
"lock-remote": REMOTE_ADMINISTRATION_ACTIONS.LOCK,
"unlock-remote": REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
} as const satisfies Partial<Record<CLICommand, RemoteAdministrationAction>>);
export type RemoteAdministrationCommand = keyof typeof REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
/** Return whether a CLI command belongs to the remote-administration category. */
export function isRemoteAdministrationCommand(command: CLICommand): command is RemoteAdministrationCommand {
return Object.prototype.hasOwnProperty.call(REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
}
function detailMessage(detail: unknown): string {
return detail instanceof Error ? detail.message : String(detail);
}
function reportMilestoneObservation(
standardIo: StandardIo,
observation: Extract<
RemoteAdministrationResult["observation"],
{ kind: typeof REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
>
): void {
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
standardIo.writeStderr(
`[Verification] Current Device Node ID (${observation.nodeId}): ${observation.accepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
);
}
/** Map typed provider observations to the CLI's established verification output. */
function reportRemoteAdministrationResult(standardIo: StandardIo, result: RemoteAdministrationResult): void {
if (result.observation?.kind === REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
reportMilestoneObservation(standardIo, result.observation);
return;
}
if (isRemoteAdministrationVerified(result)) {
return;
}
switch (result.reason) {
case REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
standardIo.writeStderr("[Verification] No active replicator found\n");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
standardIo.writeStderr(
`[Verification] Failed to connect to remote CouchDB: ${detailMessage(result.detail)}\n`
);
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
standardIo.writeStderr(
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
);
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
return;
}
}
/**
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
* Mutation exceptions deliberately escape this boundary.
*/
export async function runRemoteAdministrationCommand(
options: CLIOptions,
context: CLICommandContext,
command: RemoteAdministrationCommand
): Promise<boolean> {
const id = options.commandArgs[0]?.trim();
if (id) {
let switched = false;
await context.core.services.setting.updateSettings((currentSettings) => {
const activated = activateRemoteConfiguration(currentSettings, id);
if (activated) {
switched = true;
return activated;
}
return currentSettings;
}, false);
if (!switched) {
context.core.services.context.standardIo.writeStderr(
`[Info] Failed to temporarily activate remote configuration: ${id}\n`
);
return false;
}
await context.core.services.control.applySettings();
}
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
const action = REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
const result = await context.core.services.replicator.runRemoteAdministration({ action });
reportRemoteAdministrationResult(context.core.services.context.standardIo, result);
return isRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
}
+13 -148
View File
@@ -2,12 +2,8 @@ import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/AP
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
MILESTONE_DOCID,
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
REMOTE_COUCHDB,
REMOTE_MINIO,
type EntryMilestoneInfo,
type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
@@ -23,74 +19,20 @@ import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatur
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
import {
isReplicationCompleted,
NO_INTERACTION,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import { isRemoteAdministrationCommand, runRemoteAdministrationCommand } from "./remoteAdministration";
function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
}
async function verifyRemoteState(
core: CLICommandContext["core"],
settings: ObsidianLiveSyncSettings
): Promise<boolean> {
const { standardIo } = core.services.context;
const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) {
standardIo.writeStderr("[Verification] No active replicator found\n");
return false;
}
if (!replicator.nodeid) {
await replicator.initializeDatabaseForReplication();
}
try {
let milestone: EntryMilestoneInfo | false | undefined = undefined;
if (settings.remoteType === REMOTE_COUCHDB) {
const dbRet = await (replicator as LiveSyncCouchDBReplicator).connectRemoteCouchDBWithSetting(
settings,
false,
true
);
if (typeof dbRet === "string") {
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
return false;
}
try {
milestone = await dbRet.db.get(MILESTONE_DOCID);
} finally {
await dbRet.db.close();
}
} else if (settings.remoteType === REMOTE_MINIO) {
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
}
if (milestone) {
const isLocked = !!milestone.locked;
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
standardIo.writeStderr(
`[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
);
return true;
} else {
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
return false;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
standardIo.writeStderr(`[Verification] Failed to fetch milestone document: ${message}\n`);
return false;
}
}
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, replicationScheduling, settingsPath } = context;
const { standardIo } = core.services.context;
@@ -781,88 +723,8 @@ 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) {
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
writeStderrLine(standardIo, `[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) {
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
writeStderrLine(standardIo, `[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) {
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
await core.services.replication.markLocked();
const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings);
return true;
if (isRemoteAdministrationCommand(options.command)) {
return await runRemoteAdministrationCommand(options, context, options.command);
}
if (options.command === "remote-status") {
@@ -887,13 +749,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`);
const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) {
standardIo.writeStderr("[Error] No active replicator found\n");
const settings = core.services.setting.currentSettings();
const resource = await core.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
settings
);
if (!resource) {
standardIo.writeStderr("[Error] Remote status is unavailable for the current provider\n");
return false;
}
const settings = core.services.setting.currentSettings();
const status = await replicator.getRemoteStatus(settings);
const status = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.getStatus());
if (status === false) {
standardIo.writeStderr("[Error] Failed to fetch remote status\n");
return false;
+160 -20
View File
@@ -2,10 +2,22 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
import {
REMOTE_ADMINISTRATION_ACTIONS,
REMOTE_ADMINISTRATION_FAILURE_REASONS,
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
REMOTE_ADMINISTRATION_RESULT_STATUSES,
REMOTE_RESOURCE_KINDS,
} from "@vrtmrz/livesync-commonlib/replication";
function createStandardIoMock() {
return {
@@ -46,6 +58,23 @@ function createCoreMock() {
markLocked: vi.fn(async () => {}),
},
replicator: {
runRemoteAdministration: vi.fn(async ({ action }) => ({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
observation: {
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
locked: action === REMOTE_ADMINISTRATION_ACTIONS.LOCK,
accepted: true,
nodeId: "test-node-id",
},
})),
createRemoteResource: vi.fn(async () => ({
check: vi.fn(async () => ({ ok: true as const })),
getStatus: vi.fn(async () => ({
db_name: "test-db",
doc_count: 42,
})),
dispose: vi.fn(async () => undefined),
})),
getActiveReplicator: vi.fn(() => ({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => {}),
@@ -93,6 +122,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
databasePath: "/tmp/vault",
verbose: false,
force: false,
compatRemoteAdminExitZero: false,
};
}
@@ -706,28 +736,110 @@ describe("runCommand abnormal cases", () => {
});
describe("mark-resolved and unlock-remote commands", () => {
it("fails by default when remote administration cannot verify its postcondition", async () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
core,
});
expect(result).toBe(false);
});
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
});
const result = await runCommand(
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
{
...context,
core,
}
);
expect(result).toBe(true);
});
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
const core = createCoreMock();
const failure = new Error("mutation failed");
core.services.replicator.runRemoteAdministration.mockRejectedValueOnce(failure);
await expect(
runCommand(
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
{
...context,
core,
}
)
).rejects.toBe(failure);
});
it("does not hide an unknown remote ID behind the compatibility option", async () => {
const core = createCoreMock();
const result = await runCommand(
{ ...makeOptions("mark-resolved", ["missing-remote"]), compatRemoteAdminExitZero: true },
{
...context,
core,
}
);
expect(result).toBe(false);
expect(core.services.replicator.runRemoteAdministration).not.toHaveBeenCalled();
});
it("fails a lock command when the observed milestone remains unlocked", async () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
observation: {
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
locked: false,
accepted: true,
nodeId: "test-node-id",
},
});
const result = await runCommand(makeOptions("lock-remote", []), {
...context,
core,
});
expect(result).toBe(false);
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
.map(([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
)
.join("");
expect(verificationOutput).toContain("[Verification] Remote Database: UNLOCKED\n");
expect(verificationOutput).toContain("[Verification] Current Device Node ID (test-node-id): ACCEPTED\n");
});
it("mark-resolved without args runs on active database", async () => {
const core = createCoreMock();
const remoteDatabase = {
close: vi.fn(async () => undefined),
get: vi.fn(async () => ({
locked: false,
accepted_nodes: ["test-node-id"],
})),
};
core.services.replicator.getActiveReplicator.mockReturnValueOnce({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => undefined),
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
core,
});
expect(result).toBe(true);
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
expect(remoteDatabase.close).toHaveBeenCalledOnce();
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
});
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
@@ -745,7 +857,9 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
@@ -758,7 +872,9 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
@@ -777,7 +893,9 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
@@ -790,7 +908,9 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
@@ -809,7 +929,9 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
@@ -817,6 +939,17 @@ describe("runCommand abnormal cases", () => {
it("remote-status without args outputs status of active remote configuration", async () => {
const core = createCoreMock();
const getStatus = vi.fn(async () => ({
db_name: "test-db",
doc_count: 42,
}));
const dispose = vi.fn(async () => undefined);
const createRemoteResource = vi.fn(async () => ({
check: vi.fn(),
getStatus,
dispose,
}));
core.services.replicator.createRemoteResource = createRemoteResource;
const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-status", []), {
...context,
@@ -827,6 +960,13 @@ describe("runCommand abnormal cases", () => {
const parsedStatus = JSON.parse(fullOutput);
expect(parsedStatus.db_name).toBe("test-db");
expect(parsedStatus.doc_count).toBe(42);
expect(createRemoteResource).toHaveBeenCalledWith(
REMOTE_RESOURCE_KINDS.CONNECTION,
core.services.setting.currentSettings()
);
expect(getStatus).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
});
it("remote-status with remote-id temporarily activates it and outputs status", async () => {
+2
View File
@@ -42,6 +42,8 @@ export interface CLIOptions {
debug?: boolean;
force?: boolean;
writeSettings?: boolean;
/** Restore the former zero exit code after a returned remote-administration verification failure. */
compatRemoteAdminExitZero?: boolean;
command: CLICommand;
commandArgs: string[];
interval?: number;
+7
View File
@@ -103,6 +103,8 @@ Options:
(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
--write-settings Write setting changes after a successful command
--compat-remote-admin-exit-zero
Preserve the former zero exit code when remote-administration verification fails
Examples:
livesync-cli ./my-database Run daemon (LiveSync mode)
@@ -153,6 +155,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
let debug = false;
let force = false;
let writeSettings = false;
let compatRemoteAdminExitZero = false;
let interval: number | undefined;
let command: CLICommand = "daemon";
const commandArgs: string[] = [];
@@ -212,6 +215,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
case "--write-settings":
writeSettings = true;
break;
case "--compat-remote-admin-exit-zero":
compatRemoteAdminExitZero = true;
break;
default: {
if (!databasePath) {
if (command === "daemon" && isCLICommand(token)) {
@@ -253,6 +259,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
debug,
force,
writeSettings,
compatRemoteAdminExitZero,
command,
commandArgs,
interval,
+10
View File
@@ -69,6 +69,7 @@ describe("CLI parseArgs", () => {
const combined = standardIo.writeStdout.mock.calls.flat().join("");
expect(combined).toContain("Usage:");
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
expect(combined).toContain("--compat-remote-admin-exit-zero");
});
it("parses p2p-peers command and timeout", () => {
@@ -215,4 +216,13 @@ describe("CLI parseArgs", () => {
expect(parsed.writeSettings).toBe(true);
expect(parsed.commandArgs).toEqual([]);
});
it("parses the remote-administration exit compatibility option globally", () => {
process.argv = ["node", "livesync-cli", "./vault", "--compat-remote-admin-exit-zero", "mark-resolved"];
const parsed = parseArgs();
expect(parsed.command).toBe("mark-resolved");
expect(parsed.compatRemoteAdminExitZero).toBe(true);
expect(parsed.commandArgs).toEqual([]);
});
});