mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-17 16:17:07 +00:00
Integrate systemd CLI installer fix with current main
This commit is contained in:
@@ -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,137 @@
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
isCentralRemoteAdministrationVerified,
|
||||
type CentralRemoteAdministrationAction,
|
||||
type CentralRemoteAdministrationResult,
|
||||
} 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 CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
||||
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
} as const satisfies Partial<Record<CLICommand, CentralRemoteAdministrationAction>>);
|
||||
|
||||
export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
||||
|
||||
/** Return whether a CLI command belongs to the central-remote administration category. */
|
||||
export function isCentralRemoteAdministrationCommand(
|
||||
command: CLICommand
|
||||
): command is CentralRemoteAdministrationCommand {
|
||||
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
||||
}
|
||||
|
||||
function detailMessage(detail: unknown): string {
|
||||
return detail instanceof Error ? detail.message : String(detail);
|
||||
}
|
||||
|
||||
function assertNeverCentralRemoteAdministrationFailureReason(reason: never): never {
|
||||
throw new Error(`Unexpected central remote administration failure reason: ${String(reason)}`);
|
||||
}
|
||||
|
||||
function reportMilestoneObservation(
|
||||
standardIo: StandardIo,
|
||||
observation: Extract<
|
||||
CentralRemoteAdministrationResult["observation"],
|
||||
{ kind: typeof CENTRAL_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 reportCentralRemoteAdministrationResult(
|
||||
standardIo: StandardIo,
|
||||
result: CentralRemoteAdministrationResult
|
||||
): void {
|
||||
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
||||
reportMilestoneObservation(standardIo, result.observation);
|
||||
return;
|
||||
}
|
||||
if (isCentralRemoteAdministrationVerified(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = result.reason;
|
||||
switch (reason) {
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH:
|
||||
standardIo.writeStderr(
|
||||
"[Verification] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
||||
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
||||
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
||||
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
||||
return;
|
||||
default:
|
||||
return assertNeverCentralRemoteAdministrationFailureReason(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
||||
* Mutation exceptions deliberately escape this boundary.
|
||||
*/
|
||||
export async function runCentralRemoteAdministrationCommand(
|
||||
options: CLIOptions,
|
||||
context: CLICommandContext,
|
||||
command: CentralRemoteAdministrationCommand
|
||||
): 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 = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
||||
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
|
||||
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
||||
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
@@ -38,7 +39,7 @@ function createCoreMock() {
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
@@ -87,6 +88,17 @@ const baseContext = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
|
||||
return {
|
||||
...baseContext,
|
||||
core,
|
||||
replicationScheduling: {
|
||||
setExternalPollingMode: vi.fn(),
|
||||
markInitialOneShotSatisfied: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -101,7 +113,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -110,7 +122,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -120,9 +132,11 @@ describe("daemon command", () => {
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(30), context);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(context.replicationScheduling.setExternalPollingMode).toHaveBeenCalledWith(true);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
@@ -131,7 +145,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
@@ -144,7 +158,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -164,7 +178,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
@@ -182,7 +196,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
([chunk]: [string | Uint8Array]) =>
|
||||
@@ -194,37 +208,50 @@ describe("daemon command", () => {
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
return { status: "completed" as const };
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(), context);
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(context.replicationScheduling.markInitialOneShotSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => ({
|
||||
status: "failed" as const,
|
||||
error: new Error("initial replication failed"),
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
@@ -242,17 +269,17 @@ describe("daemon command", () => {
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
return { status: "completed" as const }; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
@@ -297,14 +324,14 @@ describe("daemon command", () => {
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { P2P_DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { getPeerConnectionStats } from "@vrtmrz/livesync-commonlib/compat/rpc/transports/DiagRTCPeerConnections.utils";
|
||||
import type { P2PPeerConnectionMetrics, P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { fsPromises } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
type CLIP2PPeer = {
|
||||
@@ -12,17 +11,13 @@ type CLIP2PPeer = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CandidateSummary = {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
type CLIP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer" | "diagnostics">;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Parse a CLI timeout expressed as a finite, non-negative number of seconds. */
|
||||
export function parseTimeoutSeconds(value: string, commandName: string): number {
|
||||
const timeoutSec = Number(value);
|
||||
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
|
||||
@@ -43,35 +38,36 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, never>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
function requireP2PService(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
service: CLIP2PService | undefined
|
||||
): CLIP2PService {
|
||||
validateP2PSettings(core);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
if (!replicator) {
|
||||
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||
if (!service) {
|
||||
throw new Error("P2P service is not available. Ensure the P2P feature was composed for this CLI process.");
|
||||
}
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||
}
|
||||
return replicator;
|
||||
return service;
|
||||
}
|
||||
|
||||
function getSortedPeers(replicator: LiveSyncTrysteroReplicator): CLIP2PPeer[] {
|
||||
return [...replicator.knownAdvertisements]
|
||||
function getSortedPeers(service: Pick<P2PServiceViews, "peerDirectory">): CLIP2PPeer[] {
|
||||
return [...service.peerDirectory.getPeers()]
|
||||
.map((peer) => ({ peerId: peer.peerId, name: peer.name }))
|
||||
.sort((a, b) => a.peerId.localeCompare(b.peerId));
|
||||
}
|
||||
|
||||
/** Connect for a bounded discovery interval, return a stable peer ordering, and disconnect. */
|
||||
export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
return getSortedPeers(replicator);
|
||||
return getSortedPeers(service);
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,32 +86,8 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getReportValue<T extends string | number>(
|
||||
report: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): T | "unknown" {
|
||||
const value = report?.[key];
|
||||
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
|
||||
}
|
||||
|
||||
function summariseCandidate(reports: unknown[], candidateId: string): CandidateSummary | undefined {
|
||||
if (candidateId === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidateId,
|
||||
candidateType: getReportValue<string>(report, "candidateType"),
|
||||
protocol: getReportValue<string>(report, "protocol"),
|
||||
relayProtocol: getReportValue<string>(report, "relayProtocol"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePeerConnectionStatsIfRequested(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: Pick<P2PServiceViews, "diagnostics">,
|
||||
peer: CLIP2PPeer
|
||||
): Promise<void> {
|
||||
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
|
||||
@@ -123,21 +95,30 @@ async function writePeerConnectionStatsIfRequested(
|
||||
return;
|
||||
}
|
||||
|
||||
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
|
||||
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
|
||||
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
|
||||
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
|
||||
const stats = await service.diagnostics.getPeerConnectionMetrics(peer.peerId);
|
||||
const payload = createPeerConnectionStatsPayload(peer, stats, new Date().toISOString());
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Build the stable JSONL record consumed by the P2P benchmark harnesses. */
|
||||
export function createPeerConnectionStatsPayload(
|
||||
peer: CLIP2PPeer,
|
||||
stats: P2PPeerConnectionMetrics | undefined,
|
||||
generatedAt: string
|
||||
) {
|
||||
const localCandidate = stats?.localCandidate;
|
||||
const remoteCandidate = stats?.remoteCandidate;
|
||||
const selectedPath =
|
||||
localCandidate && remoteCandidate
|
||||
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
|
||||
: "unknown";
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
command: "p2p-sync",
|
||||
peerId: peer.peerId,
|
||||
peerName: peer.name,
|
||||
candidatePathCollected: !!stats?.selectedPair,
|
||||
candidatePathCollected: stats?.selectedPairPresent ?? false,
|
||||
selectedPath,
|
||||
selectedPair: stats
|
||||
? {
|
||||
@@ -155,23 +136,25 @@ async function writePeerConnectionStatsIfRequested(
|
||||
localCandidate,
|
||||
remoteCandidate,
|
||||
};
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Resolve one peer token, complete pull then push, and disconnect on every settlement. */
|
||||
export async function syncWithPeer(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const start = Date.now();
|
||||
let targetPeer: CLIP2PPeer | undefined;
|
||||
|
||||
while (Date.now() - start <= timeoutMs) {
|
||||
const peers = getSortedPeers(replicator);
|
||||
const peers = getSortedPeers(service);
|
||||
targetPeer = resolvePeer(peers, peerToken);
|
||||
if (targetPeer) {
|
||||
break;
|
||||
@@ -183,11 +166,14 @@ export async function syncWithPeer(
|
||||
throw new Error(`Peer '${peerToken}' was not found within ${timeoutSec} seconds`);
|
||||
}
|
||||
|
||||
const pullResult = await replicator.replicateFrom(targetPeer.peerId, false);
|
||||
const pullResult = await service.targetedTransfer.pullFromPeer(targetPeer.peerId, { showNotice: false });
|
||||
if (pullResult && "error" in pullResult && pullResult.error) {
|
||||
throw pullResult.error instanceof Error ? pullResult.error : LiveSyncError.fromError(pullResult.error);
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(targetPeer.peerId);
|
||||
if (!pullResult || pullResult.status !== "completed") {
|
||||
throw LiveSyncError.fromError("P2P sync failed while pulling from peer");
|
||||
}
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(targetPeer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
const err: unknown = pushResult && "error" in pushResult ? pushResult.error : undefined;
|
||||
throw err instanceof Error
|
||||
@@ -195,15 +181,19 @@ export async function syncWithPeer(
|
||||
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
||||
}
|
||||
|
||||
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
|
||||
await writePeerConnectionStatsIfRequested(service, targetPeer);
|
||||
return targetPeer;
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
/** Connect the headless P2P host and transfer transport ownership to the caller. */
|
||||
export async function openP2PHost(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined
|
||||
): Promise<CLIP2PService> {
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimeoutSeconds } from "./p2p";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectPeers, createPeerConnectionStatsPayload, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
|
||||
function createCore() {
|
||||
const settings = { P2P_Enabled: true, P2P_AppID: "app-id", P2P_IsHeadless: false };
|
||||
return {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
replicator: { getNewReplicator: vi.fn(() => Promise.reject(new Error("must not be called"))) },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createP2PService() {
|
||||
const connect = vi.fn(async () => undefined);
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const pullFromPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
const requestPushToPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
return {
|
||||
service: {
|
||||
transportLifecycle: { isConnected: false, connect, disconnect },
|
||||
peerDirectory: {
|
||||
getPeers: () => [{ peerId: "peer-a", name: "Peer A", platform: "test" }],
|
||||
},
|
||||
targetedTransfer: {
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
synchroniseWithPeer: vi.fn(),
|
||||
},
|
||||
diagnostics: { requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
},
|
||||
connect,
|
||||
disconnect,
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("p2p command helpers", () => {
|
||||
it("accepts non-negative timeout", () => {
|
||||
@@ -15,4 +50,98 @@ describe("p2p command helpers", () => {
|
||||
"p2p-sync requires a non-negative timeout in seconds"
|
||||
);
|
||||
});
|
||||
|
||||
it("collects peers through service views without acquiring a concrete replicator", async () => {
|
||||
const { service, connect, disconnect } = createP2PService();
|
||||
|
||||
await expect(collectPeers(createCore(), service as never, 0)).resolves.toEqual([
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
]);
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("synchronises through the targeted-transfer view", async () => {
|
||||
const { service, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).resolves.toEqual({
|
||||
peerId: "peer-a",
|
||||
name: "Peer A",
|
||||
});
|
||||
expect(pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: false });
|
||||
expect(requestPushToPeer).toHaveBeenCalledWith("peer-a");
|
||||
});
|
||||
|
||||
it("rejects a cancelled pull without requesting a peer push", async () => {
|
||||
const { service, disconnect, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
pullFromPeer.mockResolvedValue({ status: "cancelled" } as never);
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).rejects.toBeDefined();
|
||||
|
||||
expect(requestPushToPeer).not.toHaveBeenCalled();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the benchmark diagnostics JSONL contract", () => {
|
||||
expect(
|
||||
createPeerConnectionStatsPayload(
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
{
|
||||
selectedPairPresent: true,
|
||||
selectedPairId: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
},
|
||||
"2026-08-27T00:00:00.000Z"
|
||||
)
|
||||
).toEqual({
|
||||
generatedAt: "2026-08-27T00:00:00.000Z",
|
||||
command: "p2p-sync",
|
||||
peerId: "peer-a",
|
||||
peerName: "Peer A",
|
||||
candidatePathCollected: true,
|
||||
selectedPath: "host<->relay",
|
||||
selectedPair: {
|
||||
id: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
},
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,71 +19,27 @@ 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 {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
isCentralRemoteAdministrationCommand,
|
||||
runCentralRemoteAdministrationCommand,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
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, settingsPath } = context;
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
@@ -95,19 +47,28 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
|
||||
|
||||
// The daemon owns its own recurring poller. Suppress the application
|
||||
// resume starter and generic periodic timer before restoring settings.
|
||||
replicationScheduling.setExternalPollingMode(!!options.interval);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
// 1. Replicate the configured remote into the local database so the
|
||||
// mirror scan has content to work with.
|
||||
log("Replicating from remote...");
|
||||
const replResult = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(replResult)) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
replicationScheduling.markInitialOneShotSatisfied();
|
||||
log("Initial replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
@@ -129,8 +90,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
// The provider-independent scheduling feature owns any eligible
|
||||
// Continuous start; the daemon marker suppresses a duplicate
|
||||
// sync-on-start OneShot.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
@@ -153,7 +115,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
const result = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
throw new Error(`Daemon polling replication did not complete (${result.status}).`);
|
||||
}
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
@@ -182,11 +150,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
// The applySettings() lifecycle fires onResumed → the provider-
|
||||
// independent scheduling feature, which starts Continuous when
|
||||
// supported. Do not call a concrete Replicator directly.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
@@ -204,13 +172,20 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
if (options.command === "sync") {
|
||||
writeStdoutLine(standardIo, "[Command] sync");
|
||||
const result = await core.services.replication.replicate(true);
|
||||
if (!result) {
|
||||
const result = await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
// TODO: Standardise the logic for identifying the cause of replication
|
||||
// failure so that every reason (locked DB, version mismatch, network
|
||||
// error, etc.) is surfaced with a CLI-specific actionable message.
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
|
||||
if (
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||
) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
||||
@@ -218,7 +193,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
);
|
||||
}
|
||||
}
|
||||
return !!result;
|
||||
return isReplicationCompleted(result);
|
||||
}
|
||||
|
||||
if (options.command === "p2p-peers") {
|
||||
@@ -227,7 +202,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers");
|
||||
writeStderrLine(standardIo, `[Command] p2p-peers timeout=${timeoutSec}s`);
|
||||
const peers = await collectPeers(core, timeoutSec);
|
||||
const peers = await collectPeers(core, context.p2pReplicator, timeoutSec);
|
||||
if (peers.length > 0) {
|
||||
standardIo.writeStdout(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
|
||||
}
|
||||
@@ -244,14 +219,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync");
|
||||
writeStderrLine(standardIo, `[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
|
||||
const peer = await syncWithPeer(core, peerToken, timeoutSec);
|
||||
const peer = await syncWithPeer(core, context.p2pReplicator, peerToken, timeoutSec);
|
||||
writeStderrLine(standardIo, `[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "p2p-host") {
|
||||
writeStderrLine(standardIo, "[Command] p2p-host");
|
||||
await openP2PHost(core);
|
||||
await openP2PHost(core, context.p2pReplicator);
|
||||
writeStderrLine(standardIo, "[Ready] P2P host is running. Press Ctrl+C to stop.");
|
||||
await new Promise(() => {});
|
||||
return true;
|
||||
@@ -757,88 +732,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 (isCentralRemoteAdministrationCommand(options.command)) {
|
||||
return await runCentralRemoteAdministrationCommand(options, context, options.command);
|
||||
}
|
||||
|
||||
if (options.command === "remote-status") {
|
||||
@@ -863,13 +758,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;
|
||||
|
||||
@@ -2,10 +2,26 @@ 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 {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -44,8 +60,26 @@ function createCoreMock() {
|
||||
markResolved: vi.fn(async () => {}),
|
||||
markUnlocked: vi.fn(async () => {}),
|
||||
markLocked: vi.fn(async () => {}),
|
||||
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
},
|
||||
replicator: {
|
||||
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: action === CENTRAL_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 +127,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
compatRemoteAdminExitZero: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,6 +266,42 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retains visible progress for the interactive sync command", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("remote database is locked")
|
||||
);
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pull returns false for non-existing path", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
@@ -706,28 +777,158 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
describe("mark-resolved and unlock-remote commands", () => {
|
||||
it("reports a connection failure without claiming that every central remote is CouchDB", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: new Error("remote unavailable"),
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...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] Failed to connect to the configured remote: remote unavailable\n"
|
||||
);
|
||||
expect(verificationOutput).not.toContain("CouchDB");
|
||||
});
|
||||
|
||||
it("reports when the active remote configuration changes before administration begins", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...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] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_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.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_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.runCentralRemoteAdministration.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.runCentralRemoteAdministration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: {
|
||||
kind: CENTRAL_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.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_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 +946,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_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 +961,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -777,7 +982,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_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 +997,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -809,7 +1018,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_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 +1028,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 +1049,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 () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -41,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;
|
||||
@@ -50,6 +53,8 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
core: LiveSyncBaseCore<NodeServiceContext, never>;
|
||||
/** Host-composition view used only to coordinate daemon-owned recurring work. */
|
||||
replicationScheduling: ReplicationSchedulingControl;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
|
||||
+24
-5
@@ -23,8 +23,8 @@ import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types
|
||||
import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
@@ -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,
|
||||
@@ -290,7 +297,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -420,7 +430,10 @@ export async function main(
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ export async function main(
|
||||
|
||||
// Create LiveSync core
|
||||
let p2pReplicator: UseP2PReplicatorResult | undefined;
|
||||
let replicationScheduling: ReplicationSchedulingControl | undefined;
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -479,7 +493,8 @@ export async function main(
|
||||
},
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
@@ -511,6 +526,9 @@ export async function main(
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!replicationScheduling) {
|
||||
throw new Error("Replication scheduling was not provided during core feature composition.");
|
||||
}
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -617,6 +635,7 @@ export async function main(
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
replicationScheduling,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.21-cli",
|
||||
"version": "1.0.23-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||
import { parseTimeoutSeconds } from "@/apps/cli/commands/p2p";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement.test";
|
||||
|
||||
if (
|
||||
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
|
||||
|
||||
+44
-35
@@ -1,6 +1,6 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { openP2PHost } from "@/apps/cli/commands/p2p";
|
||||
|
||||
@@ -15,32 +15,35 @@ function describeError(value: unknown): string {
|
||||
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
||||
}
|
||||
|
||||
async function waitForServing(replicator: LiveSyncTrysteroReplicator, timeoutMs: number): Promise<void> {
|
||||
type ProbeP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer">;
|
||||
|
||||
async function waitForServing(service: ProbeP2PService, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
if (replicator.server?.isServing) return;
|
||||
if (service.transportLifecycle.isConnected) return;
|
||||
await delay(200);
|
||||
}
|
||||
throw new Error("The replacement P2P replicator did not start serving within the timeout");
|
||||
throw new Error("The stable P2P service did not start serving within the timeout");
|
||||
}
|
||||
|
||||
async function waitForPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
const peer = replicator.knownAdvertisements.find(
|
||||
(candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer
|
||||
);
|
||||
const peer = service.peerDirectory
|
||||
.getPeers()
|
||||
.find((candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer);
|
||||
if (peer) return peer;
|
||||
await delay(200);
|
||||
}
|
||||
const knownPeers = replicator.knownAdvertisements.map((peer) => `${peer.name} (${peer.peerId})`).join(", ");
|
||||
throw new Error(
|
||||
`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`
|
||||
);
|
||||
const knownPeers = service.peerDirectory
|
||||
.getPeers()
|
||||
.map((peer) => `${peer.name} (${peer.peerId})`)
|
||||
.join(", ");
|
||||
throw new Error(`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`);
|
||||
}
|
||||
|
||||
function assertPullSucceeded(result: unknown): void {
|
||||
@@ -50,15 +53,17 @@ function assertPullSucceeded(result: unknown): void {
|
||||
}
|
||||
|
||||
async function communicateWithPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
await replicator.open();
|
||||
await waitForServing(replicator, timeoutMs);
|
||||
const peer = await waitForPeer(replicator, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await replicator.replicateFrom(peer.peerId, false));
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
if (!service.transportLifecycle.isConnected) {
|
||||
await service.transportLifecycle.connect();
|
||||
}
|
||||
await waitForServing(service, timeoutMs);
|
||||
const peer = await waitForPeer(service, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await service.targetedTransfer.pullFromPeer(peer.peerId, { showNotice: false }));
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
throw new Error(`P2P push failed: ${describeError(pushResult?.error)}`);
|
||||
}
|
||||
@@ -78,35 +83,39 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
throw new Error("The CLI did not expose its P2P service-feature result to the integration probe");
|
||||
}
|
||||
|
||||
const firstReplicator = await openP2PHost(core);
|
||||
if (p2pReplicator.replicator !== firstReplicator) {
|
||||
throw new Error("The P2P service feature did not expose the newly created replicator");
|
||||
const initialActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!initialActiveReplicator) {
|
||||
throw new Error("The CLI did not activate the initial P2P Replicator adapter");
|
||||
}
|
||||
const compatibilityFacade = p2pReplicator.replicator;
|
||||
const p2pService = await openP2PHost(core, p2pReplicator);
|
||||
|
||||
const firstPeer = await communicateWithPeer(firstReplicator, targetPeer, timeoutMs);
|
||||
const firstPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false);
|
||||
if (!initialised) {
|
||||
throw new Error("Database reinitialisation failed during the P2P replacement probe");
|
||||
}
|
||||
|
||||
const replacementReplicator = p2pReplicator.replicator;
|
||||
if (core.services.replicator.getActiveReplicator() !== replacementReplicator) {
|
||||
throw new Error("ReplicatorService did not activate the P2P service feature's replacement replicator");
|
||||
const replacementActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replacementActiveReplicator) {
|
||||
throw new Error("ReplicatorService did not activate a replacement P2P Replicator adapter");
|
||||
}
|
||||
if (replacementReplicator === firstReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous P2P replicator instance");
|
||||
if (replacementActiveReplicator === initialActiveReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous active P2P Replicator adapter");
|
||||
}
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("The previous P2P replicator remained open after replacement");
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("Database reinitialisation replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
if (p2pService.transportLifecycle.isConnected) {
|
||||
throw new Error("Database reinitialisation left the database-bound P2P room open");
|
||||
}
|
||||
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.P2P_AutoStart = true;
|
||||
await core.services.control.applySettings();
|
||||
const resumedReplicator = p2pReplicator.replicator;
|
||||
await waitForServing(resumedReplicator, timeoutMs);
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("A setting event reopened the previous P2P replicator");
|
||||
await waitForServing(p2pService, timeoutMs);
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("A setting event replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
|
||||
const encoded = new TextEncoder().encode(noteContent);
|
||||
@@ -118,7 +127,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
});
|
||||
await core.serviceModules.fileHandler.storeFileToDB(notePath as FilePathWithPrefix, true);
|
||||
|
||||
const replacementPeer = await communicateWithPeer(resumedReplicator, targetPeer, timeoutMs);
|
||||
const replacementPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
if (replacementPeer.name !== firstPeer.name) {
|
||||
throw new Error(
|
||||
`The replacement replicator reached '${replacementPeer.name}' instead of the original peer '${firstPeer.name}'`
|
||||
@@ -126,7 +135,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
}
|
||||
|
||||
core.services.context.standardIo.writeStdout(
|
||||
`[Probe] P2P replicator replaced, old transport stayed closed, and ${notePath} was sent through the replacement.\n`
|
||||
`[Probe] The active P2P adapter was replaced, the stable service reopened, and ${notePath} was sent through it.\n`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -143,8 +143,12 @@ export async function createCompressionBenchmarkDataset(options: {
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
||||
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||
await copyRepositoryFile(
|
||||
"ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
"ReplicateResultProcessor.ts"
|
||||
);
|
||||
|
||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:remote-administration-exit-codes",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
@@ -79,7 +79,6 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||
const backendPort = getFreePort();
|
||||
const proxyPort = getFreePort();
|
||||
@@ -156,8 +155,8 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/modules/core/ModuleReplicator.ts",
|
||||
"src/modules/core/ReplicateResultProcessor.ts",
|
||||
"src/serviceFeatures/replication/index.ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runReplacementProbe(
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("p2p lifecycle: replacement keeps real CLI communication on the current replicator", async () => {
|
||||
Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
|
||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
|
||||
@@ -82,11 +82,8 @@ Deno.test("p2p lifecycle: replacement keeps real CLI communication on the curren
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs);
|
||||
assert(
|
||||
probe.code === 0,
|
||||
`P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`
|
||||
);
|
||||
assertStringIncludes(probe.stdout, "[Probe] P2P replicator replaced");
|
||||
assert(probe.code === 0, `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`);
|
||||
assertStringIncludes(probe.stdout, "[Probe] The active P2P adapter was replaced");
|
||||
|
||||
const syncResult = await runCli(
|
||||
verifierVault,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("remote administration process exit policy distinguishes returned verification failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyP2pSettings(
|
||||
settingsFile,
|
||||
"remote-admin-exit-room",
|
||||
"remote-admin-exit-passphrase",
|
||||
"remote-admin-exit-tests",
|
||||
"ws://127.0.0.1:1/",
|
||||
"~.*",
|
||||
"none"
|
||||
);
|
||||
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||
|
||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||
assertEquals(defaultFailure.code, 1, defaultFailure.combined);
|
||||
assertStringIncludes(
|
||||
defaultFailure.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(defaultFailure.combined, "[Error] Command 'mark-resolved' failed");
|
||||
|
||||
const compatibilitySuccess = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||
assertStringIncludes(
|
||||
compatibilitySuccess.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
|
||||
});
|
||||
|
||||
Deno.test("remote administration compatibility does not hide a thrown mutation failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyCouchdbSettings(
|
||||
settingsFile,
|
||||
"http://127.0.0.1:1/",
|
||||
"unreachable-user",
|
||||
"unreachable-password",
|
||||
"unreachable-database"
|
||||
);
|
||||
|
||||
const mutationFailure = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
|
||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||
});
|
||||
Reference in New Issue
Block a user