Integrate systemd CLI installer fix with current main

This commit is contained in:
vorotamoroz
2026-09-02 11:43:59 +00:00
138 changed files with 8751 additions and 4714 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,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 27 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);
+55 -65
View File
@@ -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;
}
+131 -2
View File
@@ -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",
},
});
});
});
+68 -170
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,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;
+249 -20
View File
@@ -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 () => {
+6 -1
View File
@@ -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
View File
@@ -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,
+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([]);
});
});
+1 -1
View File
@@ -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" &&
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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:");
});