refactor: inject CLI I/O and diagnostics

This commit is contained in:
vorotamoroz
2026-07-17 16:01:48 +00:00
parent 1b4a0d76dd
commit 97964fddf1
23 changed files with 422 additions and 236 deletions
@@ -20,9 +20,15 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
function createCoreMock() {
const standardIo = {
readStdin: vi.fn(async () => ""),
prompt: vi.fn(async () => ""),
writeStdout: vi.fn((_chunk: string | Uint8Array) => undefined),
writeStderr: vi.fn((_chunk: string | Uint8Array) => undefined),
};
return {
services: {
context: createServiceContext(),
context: Object.assign(createServiceContext(), { standardIo }),
control: {
activated: Promise.resolve(),
applySettings: vi.fn(async () => {}),
@@ -157,13 +163,13 @@ describe("daemon command", () => {
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
expect(result).toBe(true);
const warningCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" && chunk.includes("liveSync and syncOnStart are both disabled")
);
expect(warningCalls.length).toBeGreaterThan(0);
});
@@ -175,12 +181,12 @@ describe("daemon command", () => {
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await runCommand(makeDaemonOptions(), { ...baseContext, core });
const warningCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" && chunk.includes("liveSync and syncOnStart are both disabled")
);
expect(warningCalls.length).toBe(0);
});
@@ -233,7 +239,6 @@ describe("daemon command", () => {
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
vi.spyOn(console, "error").mockImplementation(() => {});
// startup replicate (call 1) succeeds; poll calls 27 fail; call 8 succeeds.
let callCount = 0;
@@ -286,10 +291,9 @@ describe("daemon command", () => {
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000);
});
it("polling error handling: replicate rejection is caught and console.error is called", async () => {
it("polling error handling: replicate rejection is caught and written to standard error", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Make replicate succeed on the initial call (startup), then fail on the poll.
let callCount = 0;
@@ -306,8 +310,8 @@ describe("daemon command", () => {
await vi.advanceTimersByTimeAsync(intervalMs);
// No unhandled rejection — the error was caught internally.
const errorCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("Poll error")
const errorCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
([chunk]: [string | Uint8Array]) => typeof chunk === "string" && chunk.includes("Poll error")
);
expect(errorCalls.length).toBeGreaterThan(0);
});
+77 -68
View File
@@ -14,7 +14,7 @@ import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common
import { activateRemoteConfiguration, createRemoteConfigurationId } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { CLICommandContext, CLIOptions } from "./types";
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
@@ -22,6 +22,7 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
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 "../cliOutput";
function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
@@ -31,9 +32,10 @@ async function verifyRemoteState(
core: CLICommandContext["core"],
settings: ObsidianLiveSyncSettings
): Promise<boolean> {
const { standardIo } = core.services.context;
const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) {
process.stderr.write("[Verification] No active replicator found\n");
standardIo.writeStderr("[Verification] No active replicator found\n");
return false;
}
@@ -50,7 +52,7 @@ async function verifyRemoteState(
true
);
if (typeof dbRet === "string") {
process.stderr.write(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
return false;
}
milestone = await dbRet.db.get(MILESTONE_DOCID);
@@ -61,29 +63,30 @@ async function verifyRemoteState(
if (milestone) {
const isLocked = !!milestone.locked;
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
process.stderr.write(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
process.stderr.write(
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 {
process.stderr.write("[Verification] Milestone document not found on remote.\n");
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
return false;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`[Verification] Failed to fetch milestone document: ${message}\n`);
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 { standardIo } = core.services.context;
const vaultPath = context.vaultPath || databasePath;
await core.services.control.activated;
if (options.command === "daemon") {
const log = (msg: unknown) => console.error(`[Daemon] ${msg}`);
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
// and the default "Dismiss" action would block replication. The daemon should
@@ -94,7 +97,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("Replicating from CouchDB...");
const replResult = await core.services.replication.replicate(true);
if (!replResult) {
console.error("[Daemon] Initial CouchDB replication failed, cannot continue");
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
return false;
}
log("CouchDB replication complete");
@@ -104,7 +107,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("Running mirror scan...");
const scanOk = await performFullScan(core, log, errorManager, false, true);
if (!scanOk) {
console.error("[Daemon] Mirror scan failed, cannot continue");
writeStderrLine(standardIo, "[Daemon] Mirror scan failed, cannot continue");
return false;
}
log("Mirror scan complete");
@@ -152,9 +155,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} catch (err) {
consecutiveFailures++;
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
writeStderrLine(standardIo, `[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
if (consecutiveFailures >= 5) {
console.error(
writeStderrLine(
standardIo,
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
);
}
@@ -179,7 +183,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("LiveSync active");
const currentSettings = core.services.setting.currentSettings();
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
console.error(
writeStderrLine(
standardIo,
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
"or use --interval for polling mode."
@@ -191,7 +196,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
if (options.command === "sync") {
console.log("[Command] sync");
writeStdoutLine(standardIo, "[Command] sync");
const result = await core.services.replication.replicate(true);
if (!result) {
// TODO: Standardise the logic for identifying the cause of replication
@@ -199,7 +204,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// error, etc.) is surfaced with a CLI-specific actionable message.
const replicator = core.services.replicator.getActiveReplicator();
if (replicator?.remoteLockedAndDeviceNotAccepted) {
console.error(
writeStderrLine(
standardIo,
`[Error] The remote database is locked and this device is not yet accepted.\n` +
`[Error] Please unlock the database from the Obsidian plugin and retry.`
);
@@ -213,10 +219,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("p2p-peers requires one argument: <timeout>");
}
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers");
console.error(`[Command] p2p-peers timeout=${timeoutSec}s`);
writeStderrLine(standardIo, `[Command] p2p-peers timeout=${timeoutSec}s`);
const peers = await collectPeers(core, timeoutSec);
if (peers.length > 0) {
process.stdout.write(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
standardIo.writeStdout(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
}
return true;
}
@@ -230,16 +236,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("p2p-sync requires a non-empty <peer>");
}
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync");
console.error(`[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
writeStderrLine(standardIo, `[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
const peer = await syncWithPeer(core, peerToken, timeoutSec);
console.error(`[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
writeStderrLine(standardIo, `[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
return true;
}
if (options.command === "p2p-host") {
console.error("[Command] p2p-host");
writeStderrLine(standardIo, "[Command] p2p-host");
await openP2PHost(core);
console.error("[Ready] P2P host is running. Press Ctrl+C to stop.");
writeStderrLine(standardIo, "[Ready] P2P host is running. Press Ctrl+C to stop.");
await new Promise(() => {});
return true;
}
@@ -252,7 +258,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], vaultPath);
const sourceData = await fs.readFile(sourcePath);
const sourceStat = await fs.stat(sourcePath);
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
writeStdoutLine(standardIo, `[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
mtime: Math.floor(sourceStat.mtimeMs),
@@ -269,7 +275,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const destinationPath = path.resolve(options.commandArgs[1]);
console.log(`[Command] pull ${sourceDatabasePath} -> ${destinationPath}`);
writeStdoutLine(standardIo, `[Command] pull ${sourceDatabasePath} -> ${destinationPath}`);
const sourcePathWithPrefix = sourceDatabasePath as FilePathWithPrefix;
const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true);
@@ -296,7 +302,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!rev) {
throw new Error("pull-rev requires a non-empty revision");
}
console.log(`[Command] pull-rev ${sourceDatabasePath}@${rev} -> ${destinationPath}`);
writeStdoutLine(standardIo, `[Command] pull-rev ${sourceDatabasePath}@${rev} -> ${destinationPath}`);
const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix,
@@ -325,7 +331,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!setupURI.startsWith(configURIBase)) {
throw new Error(`setup URI must start with ${configURIBase}`);
}
const passphrase = await promptForPassphrase();
const passphrase = await standardIo.prompt("Enter setup URI passphrase: ");
if (!passphrase) {
throw new Error("Passphrase is required");
}
const decoded = await decodeSettingsFromSetupURI(setupURI, passphrase);
if (!decoded) {
throw new Error("Failed to decode settings from setup URI");
@@ -337,7 +346,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
isConfigured: true,
} as ObsidianLiveSyncSettings;
console.log(`[Command] setup -> ${settingsPath}`);
writeStdoutLine(standardIo, `[Command] setup -> ${settingsPath}`);
await core.services.setting.applyExternalSettings(nextSettings, true);
await core.services.control.applySettings();
return true;
@@ -348,8 +357,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("put requires one argument: <dst>");
}
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const content = await readStdinAsUtf8();
console.log(`[Command] put stdin -> ${destinationDatabasePath}`);
const content = await standardIo.readStdin();
writeStdoutLine(standardIo, `[Command] put stdin -> ${destinationDatabasePath}`);
return await core.serviceModules.databaseFileAccess.storeContent(
destinationDatabasePath as FilePathWithPrefix,
content
@@ -361,7 +370,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("cat requires one argument: <src>");
}
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
console.error(`[Command] cat ${sourceDatabasePath}`);
writeStderrLine(standardIo, `[Command] cat ${sourceDatabasePath}`);
const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix,
undefined,
@@ -372,10 +381,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
const body = source.body;
if (body.type === "text/plain") {
process.stdout.write(await body.text());
standardIo.writeStdout(await body.text());
} else {
const buffer = Buffer.from(await body.arrayBuffer());
process.stdout.write(new Uint8Array(buffer));
standardIo.writeStdout(new Uint8Array(buffer));
}
return true;
}
@@ -389,7 +398,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!rev) {
throw new Error("cat-rev requires a non-empty revision");
}
console.error(`[Command] cat-rev ${sourceDatabasePath} @ ${rev}`);
writeStderrLine(standardIo, `[Command] cat-rev ${sourceDatabasePath} @ ${rev}`);
const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix,
rev,
@@ -400,10 +409,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
const body = source.body;
if (body.type === "text/plain") {
process.stdout.write(await body.text());
standardIo.writeStdout(await body.text());
} else {
const buffer = Buffer.from(await body.arrayBuffer());
process.stdout.write(new Uint8Array(buffer));
standardIo.writeStdout(new Uint8Array(buffer));
}
return true;
}
@@ -432,9 +441,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
rows.sort((a, b) => a.path.localeCompare(b.path));
if (rows.length > 0) {
process.stdout.write(rows.map((e) => e.line).join("\n") + "\n");
standardIo.writeStdout(rows.map((e) => e.line).join("\n") + "\n");
} else {
process.stderr.write("[Info] No documents found in the local database.\n");
standardIo.writeStderr("[Info] No documents found in the local database.\n");
}
return true;
}
@@ -475,11 +484,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
chunks: children.length,
children: children,
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
standardIo.writeStdout(JSON.stringify(out, null, 2) + "\n");
return true;
}
process.stderr.write(`[Info] File not found: ${targetPath}\n`);
standardIo.writeStderr(`[Info] File not found: ${targetPath}\n`);
return false;
}
@@ -488,7 +497,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("rm requires one argument: <path>");
}
const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
console.error(`[Command] rm ${targetPath}`);
writeStderrLine(standardIo, `[Command] rm ${targetPath}`);
return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix);
}
@@ -504,30 +513,30 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const currentMeta = await core.serviceModules.databaseFileAccess.fetchEntryMeta(targetPath, undefined, true);
if (currentMeta === false || currentMeta._deleted || currentMeta.deleted) {
process.stderr.write(`[Info] File not found: ${targetPath}\n`);
standardIo.writeStderr(`[Info] File not found: ${targetPath}\n`);
return false;
}
const conflicts = await core.serviceModules.databaseFileAccess.getConflictedRevs(targetPath);
const candidateRevisions = [currentMeta._rev, ...conflicts];
if (!candidateRevisions.includes(revisionToKeep)) {
process.stderr.write(`[Info] Revision not found for ${targetPath}: ${revisionToKeep}\n`);
standardIo.writeStderr(`[Info] Revision not found for ${targetPath}: ${revisionToKeep}\n`);
return false;
}
if (conflicts.length === 0 && currentMeta._rev === revisionToKeep) {
console.error(`[Command] resolve ${targetPath} keep ${revisionToKeep} (already resolved)`);
writeStderrLine(standardIo, `[Command] resolve ${targetPath} keep ${revisionToKeep} (already resolved)`);
return true;
}
console.error(`[Command] resolve ${targetPath} keep ${revisionToKeep}`);
writeStderrLine(standardIo, `[Command] resolve ${targetPath} keep ${revisionToKeep}`);
for (const revision of candidateRevisions) {
if (revision === revisionToKeep) {
continue;
}
const resolved = await core.services.conflict.resolveByDeletingRevision(targetPath, revision ?? "", "CLI");
if (!resolved) {
process.stderr.write(`[Info] Failed to delete revision ${revision} for ${targetPath}\n`);
standardIo.writeStderr(`[Info] Failed to delete revision ${revision} for ${targetPath}\n`);
return false;
}
}
@@ -535,8 +544,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}
if (options.command === "mirror") {
console.error("[Command] mirror");
const log = (msg: unknown) => console.error(`[Mirror] ${msg}`);
writeStderrLine(standardIo, "[Command] mirror");
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
return await performFullScan(core, log, errorManager, false, true);
}
@@ -579,7 +588,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.services.control.applySettings();
}
process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
standardIo.writeStdout(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
return true;
}
@@ -594,7 +603,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const current = core.services.setting.currentSettings();
if (!current.remoteConfigurations?.[id]) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
standardIo.writeStderr(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
@@ -624,7 +633,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.services.control.applySettings();
}
console.error(`[Command] remote-rm ${id}`);
writeStderrLine(standardIo, `[Command] remote-rm ${id}`);
return true;
}
@@ -634,7 +643,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
configs.sort((a, b) => a.name.localeCompare(b.name));
if (configs.length === 0) {
process.stderr.write("[Info] No remote configurations found.\n");
standardIo.writeStderr("[Info] No remote configurations found.\n");
return true;
}
@@ -642,7 +651,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`;
});
process.stdout.write(lines.join("\n") + "\n");
standardIo.writeStdout(lines.join("\n") + "\n");
return true;
}
@@ -657,11 +666,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!config) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
standardIo.writeStderr(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
process.stdout.write(`${config.uri}\n`);
standardIo.writeStdout(`${config.uri}\n`);
return true;
}
@@ -701,7 +710,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!updated) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
standardIo.writeStderr(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
@@ -709,7 +718,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.services.control.applySettings();
}
console.error(`[Command] remote-set ${id}`);
writeStderrLine(standardIo, `[Command] remote-set ${id}`);
return true;
}
if (options.command === "remote-activate") {
@@ -732,12 +741,12 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, true);
if (!switched) {
process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`);
standardIo.writeStderr(`[Info] Failed to activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
console.error(`[Command] remote-activate ${id}`);
writeStderrLine(standardIo, `[Command] remote-activate ${id}`);
return true;
}
@@ -755,14 +764,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false);
if (!switched) {
process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
console.error(`[Command] mark-resolved${id ? ` ${id}` : ""}`);
writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`);
await core.services.replication.markResolved();
const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings);
@@ -783,14 +792,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false);
if (!switched) {
process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
console.error(`[Command] unlock-remote${id ? ` ${id}` : ""}`);
writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`);
await core.services.replication.markUnlocked();
const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings);
@@ -811,14 +820,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false);
if (!switched) {
process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
console.error(`[Command] lock-remote${id ? ` ${id}` : ""}`);
writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
await core.services.replication.markLocked();
const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings);
@@ -839,26 +848,26 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false);
if (!switched) {
process.stderr.write(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
}
console.error(`[Command] remote-status${id ? ` ${id}` : ""}`);
writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`);
const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) {
process.stderr.write("[Error] No active replicator found\n");
standardIo.writeStderr("[Error] No active replicator found\n");
return false;
}
const settings = core.services.setting.currentSettings();
const status = await replicator.getRemoteStatus(settings);
if (status === false) {
process.stderr.write("[Error] Failed to fetch remote status\n");
standardIo.writeStderr("[Error] Failed to fetch remote status\n");
return false;
}
process.stdout.write(JSON.stringify(status, null, 2) + "\n");
standardIo.writeStdout(JSON.stringify(status, null, 2) + "\n");
return true;
}
+86 -22
View File
@@ -6,7 +6,15 @@ import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrt
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
import * as commandUtils from "./utils";
function createStandardIoMock() {
return {
readStdin: vi.fn(async () => ""),
prompt: vi.fn(async () => ""),
writeStdout: vi.fn((_chunk: string | Uint8Array) => undefined),
writeStderr: vi.fn((_chunk: string | Uint8Array) => undefined),
};
}
function createCoreMock() {
const liveSettings = {
@@ -17,6 +25,9 @@ function createCoreMock() {
} as any;
return {
services: {
context: {
standardIo: createStandardIoMock(),
},
control: {
activated: Promise.resolve(),
applySettings: vi.fn(async () => {}),
@@ -69,6 +80,7 @@ function createCoreMock() {
},
databaseFileAccess: {
fetch: vi.fn(async () => undefined),
storeContent: vi.fn(async () => true),
},
},
} as any;
@@ -96,20 +108,20 @@ async function createSetupURI(passphrase: string): Promise<string> {
return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
}
function captureStdout() {
const writes: string[] = [];
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
writes.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
function captureStdout(core: ReturnType<typeof createCoreMock>) {
const spy = core.services.context.standardIo.writeStdout;
spy.mockClear();
return {
spy,
lines: () =>
writes
spy.mock.calls
.map(([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
)
.join("")
.split("\n")
.map((e) => e.trim())
.filter((e) => e.length > 0),
.map((entry: string) => entry.trim())
.filter((entry: string) => entry.length > 0),
};
}
@@ -304,7 +316,7 @@ describe("runCommand abnormal cases", () => {
it("setup rejects empty passphrase", async () => {
const core = createCoreMock();
vi.spyOn(commandUtils, "promptForPassphrase").mockRejectedValue(new Error("Passphrase is required"));
core.services.context.standardIo.prompt.mockResolvedValue("");
await expect(
runCommand(makeOptions("setup", [`${configURIBase}dummy`]), {
@@ -318,7 +330,7 @@ describe("runCommand abnormal cases", () => {
const core = createCoreMock();
const passphrase = "correct-passphrase";
const setupURI = await createSetupURI(passphrase);
vi.spyOn(commandUtils, "promptForPassphrase").mockResolvedValue(passphrase);
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
const result = await runCommand(makeOptions("setup", [setupURI]), {
...context,
@@ -339,7 +351,7 @@ describe("runCommand abnormal cases", () => {
it("setup rejects encoded URI when passphrase is wrong", async () => {
const core = createCoreMock();
const setupURI = await createSetupURI("correct-passphrase");
vi.spyOn(commandUtils, "promptForPassphrase").mockResolvedValue("wrong-passphrase");
core.services.context.standardIo.prompt.mockResolvedValue("wrong-passphrase");
await expect(
runCommand(makeOptions("setup", [setupURI]), {
@@ -352,9 +364,61 @@ describe("runCommand abnormal cases", () => {
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
it("put reads content from the injected standard input", async () => {
const core = createCoreMock();
core.services.context.standardIo.readStdin.mockResolvedValue("content from stdin");
const result = await runCommand(makeOptions("put", ["notes/input.md"]), {
...context,
core,
});
expect(result).toBe(true);
expect(core.services.context.standardIo.readStdin).toHaveBeenCalledOnce();
expect(core.serviceModules.databaseFileAccess.storeContent).toHaveBeenCalledWith(
"notes/input.md",
"content from stdin"
);
});
it("cat writes text to the injected standard output without adding a delimiter", async () => {
const core = createCoreMock();
core.serviceModules.databaseFileAccess.fetch.mockResolvedValue({
deleted: false,
body: new Blob(["exact text"], { type: "text/plain" }),
});
const result = await runCommand(makeOptions("cat", ["notes/output.md"]), {
...context,
core,
});
expect(result).toBe(true);
expect(core.services.context.standardIo.writeStdout).toHaveBeenCalledWith("exact text");
});
it("cat preserves binary bytes through the injected standard output", async () => {
const core = createCoreMock();
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff]);
core.serviceModules.databaseFileAccess.fetch.mockResolvedValue({
deleted: false,
body: new Blob([expected], { type: "application/octet-stream" }),
});
const result = await runCommand(makeOptions("cat", ["binary/output.bin"]), {
...context,
core,
});
expect(result).toBe(true);
const chunk = core.services.context.standardIo.writeStdout.mock.calls.at(-1)?.[0];
expect(chunk).toBeInstanceOf(Uint8Array);
expect([...(chunk as Uint8Array)]).toEqual([...expected]);
});
it("remote-add stores canonical URI and prints the created id", async () => {
const core = createCoreMock();
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const stdout = core.services.context.standardIo.writeStdout;
const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), {
...context,
@@ -435,7 +499,7 @@ describe("runCommand abnormal cases", () => {
uri: "sls+https://example.com/db?db=vault",
isEncrypted: false,
};
const stdout = captureStdout();
const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-export", ["r1"]), {
...context,
@@ -565,7 +629,7 @@ describe("runCommand abnormal cases", () => {
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
const core = createCoreMock();
const addOut = captureStdout();
const addOut = captureStdout(core);
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
...context,
core,
@@ -574,7 +638,7 @@ describe("runCommand abnormal cases", () => {
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
expect(remoteId).not.toBe("");
const export1Out = captureStdout();
const export1Out = captureStdout(core);
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
@@ -591,7 +655,7 @@ describe("runCommand abnormal cases", () => {
});
expect(setResult).toBe(true);
const export2Out = captureStdout();
const export2Out = captureStdout(core);
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
@@ -740,13 +804,13 @@ describe("runCommand abnormal cases", () => {
it("remote-status without args outputs status of active remote configuration", async () => {
const core = createCoreMock();
const stdout = captureStdout();
const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-status", []), {
...context,
core,
});
expect(result).toBe(true);
const fullOutput = stdout.spy.mock.calls.map((c) => c[0]).join("");
const fullOutput = stdout.spy.mock.calls.map((call: [string | Uint8Array]) => call[0]).join("");
const parsedStatus = JSON.parse(fullOutput);
expect(parsedStatus.db_name).toBe("test-db");
expect(parsedStatus.doc_count).toBe(42);
@@ -761,13 +825,13 @@ describe("runCommand abnormal cases", () => {
uri: "sls+https://example.com/db1",
isEncrypted: false,
};
const stdout = captureStdout();
const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-status", ["r1"]), {
...context,
core,
});
expect(result).toBe(true);
const fullOutput = stdout.spy.mock.calls.map((c) => c[0]).join("");
const fullOutput = stdout.spy.mock.calls.map((call: [string | Uint8Array]) => call[0]).join("");
const parsedStatus = JSON.parse(fullOutput);
expect(parsedStatus.db_name).toBe("test-db");
expect(parsedStatus.doc_count).toBe(42);
+2 -2
View File
@@ -1,6 +1,6 @@
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
export type CLICommand =
| "daemon"
@@ -47,7 +47,7 @@ export interface CLIOptions {
export interface CLICommandContext {
databasePath: string;
vaultPath: string;
core: LiveSyncBaseCore<ServiceContext, never>;
core: LiveSyncBaseCore<NodeServiceContext, never>;
settingsPath: string;
originalSyncSettings: Pick<
ObsidianLiveSyncSettings,
+1 -26
View File
@@ -1,4 +1,4 @@
import { path, readline } from "@vrtmrz/livesync-commonlib/node";
import { path } from "@vrtmrz/livesync-commonlib/node";
export function toArrayBuffer(data: Buffer): ArrayBuffer {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
@@ -22,28 +22,3 @@ export function toDatabaseRelativePath(inputPath: string, databasePath: string):
}
return rel.replace(/\\/g, "/");
}
export async function readStdinAsUtf8(): Promise<string> {
const chunks = [];
for await (const chunk of process.stdin) {
if (typeof chunk === "string") {
chunks.push(Buffer.from(chunk, "utf-8"));
} else {
chunks.push(chunk as Buffer);
}
}
return Buffer.concat(chunks as Uint8Array[]).toString("utf-8");
}
export async function promptForPassphrase(prompt = "Enter setup URI passphrase: "): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
const passphrase = await rl.question(prompt);
if (!passphrase) {
throw new Error("Passphrase is required");
}
return passphrase;
} finally {
rl.close();
}
}